Skip to content

Commit

Permalink
Implement project directory persistence for web builds
Browse files Browse the repository at this point in the history
Project directory handles are now stored in IndexedDB so that they can
be recovered after the user closes or refreshes the page.
  • Loading branch information
white-axe committed Oct 9, 2023
1 parent 9b38647 commit 1a2c304
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 19 deletions.
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,14 @@ wasm-bindgen = "0.2.84"
poll-promise = { version = "0.3.0", features = ["web"] }
tokio = { version = "1.32", features = ["sync"] }
wasm-bindgen-futures = "0.4"
indexed_db_futures = "0.3.0"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"console",
"Window",
"Document",
"Element",
"DomException",

"EventTarget",
"EventListener",
Expand Down
4 changes: 4 additions & 0 deletions assets/bindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,7 @@ export async function _remove_dir(dir) {
export function dir_values(dir) {
return dir.values();
}

export async function _request_permission(handle) {
return (await handle.requestPermission({ mode: 'readwrite' })) === 'granted'
}
5 changes: 5 additions & 0 deletions src/config/global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,13 @@ use std::collections::VecDeque;
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
#[serde(default)]
pub struct Config {
#[cfg(not(target_arch = "wasm32"))]
/// Recently open projects.
pub recent_projects: VecDeque<String>,
#[cfg(target_arch = "wasm32")]
/// Recently open projects.
pub recent_projects: VecDeque<(String, String)>,

/// The current code theme
pub theme: syntax_highlighting::CodeTheme,
pub rtp_paths: HashMap<String, String>,
Expand Down
14 changes: 14 additions & 0 deletions src/filesystem/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,9 @@ impl FileSystem {
unreachable!();
};

let root_path = dir.root_path().to_path_buf();
let idb_key = dir.idb_key().map(|k| k.to_string());

let mut list = list::FileSystem::new();

let paths = Self::find_rtp_paths(&dir);
Expand All @@ -413,6 +416,17 @@ impl FileSystem {
project_path: entry.path.clone(),
};

if let Some(idb_key) = idb_key {
let mut projects: std::collections::VecDeque<_> = global_config!()
.recent_projects
.iter()
.filter(|(_, k)| k.as_str() != idb_key.as_str())
.cloned()
.collect();
projects.push_front((root_path.join(&entry.path).to_string(), idb_key));
global_config!().recent_projects = projects;
}

if let Err(e) = state!().data_cache.load() {
*self.state.borrow_mut() = State::Unloaded;
return Err(e);
Expand Down
130 changes: 127 additions & 3 deletions src/filesystem/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
// You should have received a copy of the GNU General Public License
// along with Luminol. If not, see <http://www.gnu.org/licenses/>.
use crate::prelude::*;
use indexed_db_futures::prelude::*;
use rand::Rng;
use wasm_bindgen::prelude::*;

use crate::web::bindings;
Expand All @@ -28,6 +30,7 @@ static FILESYSTEM_TX: OnceCell<mpsc::UnboundedSender<FileSystemCommand>> = OnceC
pub struct FileSystem {
key: usize,
name: String,
idb_key: Option<String>,
}

#[derive(Debug)]
Expand All @@ -46,7 +49,9 @@ enum FileSystemCommandInner {
camino::Utf8PathBuf,
oneshot::Sender<Result<Metadata, Error>>,
),
DirPicker(oneshot::Sender<Option<(usize, String)>>),
DirPicker(oneshot::Sender<Option<(usize, String, Option<String>)>>),
DirFromIdb(String, oneshot::Sender<Option<(usize, String)>>),
DirIdbDrop(String, oneshot::Sender<bool>),
DirOpenFile(
usize,
camino::Utf8PathBuf,
Expand Down Expand Up @@ -129,14 +134,49 @@ impl FileSystem {
oneshot_rx
.await
.unwrap()
.map(|(key, name)| FileSystem { key, name })
.map(|(key, name, idb_key)| FileSystem { key, name, idb_key })
}

/// Attempts to restore a previously created `FileSystem` using its `.idb_key()`.
pub async fn from_idb_key(idb_key: String) -> Option<Self> {
if !Self::filesystem_supported() {
return None;
}
let (oneshot_tx, oneshot_rx) = oneshot::channel();
filesystem_tx_or_die()
.send(FileSystemCommand(FileSystemCommandInner::DirFromIdb(
idb_key.clone(),
oneshot_tx,
)))
.unwrap();
oneshot_rx.await.unwrap().map(|(key, name)| FileSystem {
key,
name,
idb_key: Some(idb_key),
})
}

/// Drops the directory with the given key from IndexedDB if it exists in there.
pub fn idb_drop(idb_key: String) -> bool {
let (oneshot_tx, oneshot_rx) = oneshot::channel();
filesystem_tx_or_die()
.send(FileSystemCommand(FileSystemCommandInner::DirIdbDrop(
idb_key, oneshot_tx,
)))
.unwrap();
oneshot_rx.blocking_recv().unwrap()
}

/// Returns a path consisting of a single element: the name of the root directory of this
/// filesystem.
pub fn root_path(&self) -> &camino::Utf8Path {
self.name.as_str().into()
}

/// Returns the key needed to restore this `FileSystem` using `FileSystem::from_idb()`.
pub fn idb_key(&self) -> Option<&str> {
self.idb_key.as_deref()
}
}

impl Drop for FileSystem {
Expand All @@ -162,6 +202,7 @@ impl Clone for FileSystem {
Self {
key: oneshot_rx.blocking_recv().unwrap(),
name: self.name.clone(),
idb_key: self.idb_key.clone(),
}
}
}
Expand Down Expand Up @@ -374,6 +415,32 @@ pub fn setup_main_thread_hooks(mut filesystem_rx: mpsc::UnboundedReceiver<FileSy
}
}

async fn idb<R>(
mode: IdbTransactionMode,
f: impl Fn(IdbObjectStore<'_>) -> Result<R, web_sys::DomException>,
) -> Result<R, web_sys::DomException> {
let mut db_req = IdbDatabase::open_u32("astrabit.luminol", 1)?;

// Create store for our directory handles if it doesn't exist
db_req.set_on_upgrade_needed(Some(|e: &IdbVersionChangeEvent| {
if e.db()
.object_store_names()
.find(|n| n == "filesystem.dir_handles")
.is_none()
{
e.db().create_object_store("filesystem.dir_handles")?;
}
Ok(())
}));

let db = db_req.into_future().await?;
let tx = db.transaction_on_one_with_mode("filesystem.dir_handles", mode)?;
let store = tx.object_store("filesystem.dir_handles")?;
let r = f(store);
tx.await.into_result()?;
r
}

loop {
let Some(command) = filesystem_rx.recv().await else {
tracing::warn!(
Expand Down Expand Up @@ -440,13 +507,70 @@ pub fn setup_main_thread_hooks(mut filesystem_rx: mpsc::UnboundedReceiver<FileSy

FileSystemCommandInner::DirPicker(oneshot_tx) => {
if let Ok(dir) = bindings::show_directory_picker().await {
// Try to insert the handle into IndexedDB
let idb_key = rand::thread_rng()
.sample_iter(rand::distributions::Alphanumeric)
.take(42) // This should be enough to avoid collisions
.map(char::from)
.collect::<String>();
let idb_ok = {
let idb_key = idb_key.as_str();
idb(IdbTransactionMode::Readwrite, |store| {
store.put_key_val_owned(idb_key, &dir)
})
.await
.is_ok()
};

let name = dir.name();
oneshot_tx.send(Some((dirs.insert(dir), name))).unwrap();
oneshot_tx
.send(Some((
dirs.insert(dir),
name,
if idb_ok { Some(idb_key) } else { None },
)))
.unwrap();
} else {
oneshot_tx.send(None).unwrap();
}
}

FileSystemCommandInner::DirFromIdb(idb_key, oneshot_tx) => {
let idb_key = idb_key.as_str();
if let Ok(future) = idb(IdbTransactionMode::Readonly, |store| {
store.get_owned(idb_key)
})
.await
{
if let Some(dir) = future.await.ok().flatten() {
let dir = dir.unchecked_into::<web_sys::FileSystemDirectoryHandle>();
if bindings::request_permission(&dir).await {
let name = dir.name();
oneshot_tx.send(Some((dirs.insert(dir), name))).unwrap();
} else {
oneshot_tx.send(None).unwrap();
}
} else {
oneshot_tx.send(None).unwrap();
}
} else {
oneshot_tx.send(None).unwrap();
}
}

FileSystemCommandInner::DirIdbDrop(idb_key, oneshot_tx) => {
let idb_key = idb_key.as_str();
oneshot_tx
.send(
idb(IdbTransactionMode::Readwrite, |store| {
store.delete_owned(idb_key)
})
.await
.is_ok(),
)
.unwrap();
}

FileSystemCommandInner::DirOpenFile(key, path, flags, oneshot_tx) => {
let mut iter = path.iter();
let Some(filename) = iter.next_back() else {
Expand Down
50 changes: 34 additions & 16 deletions src/tabs/started.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,25 +96,43 @@ impl tab::Tab for Tab {
ui.heading("Recent");

for path in &global_config!().recent_projects {
#[cfg(target_arch = "wasm32")]
let (path, idb_key) = path;

if ui.button(path).clicked() {
let path = path.clone();
#[cfg(target_arch = "wasm32")]
let idb_key = idb_key.clone();

self.load_project_promise =
Some(Promise::spawn_local(async move {
#[cfg(not(target_arch = "wasm32"))]
let result = state.filesystem.load_project(path);

#[cfg(target_arch = "wasm32")]
let result =
match filesystem::web::FileSystem::from_idb_key(idb_key.clone())
.await
{
Some(dir) => state.filesystem.load_project(dir),
None => Err("Could not restore project handle from IndexedDB"
.to_string()),
};

self.load_project_promise = Some(Promise::spawn_local(async move {
#[cfg(not(target_arch = "wasm32"))]
if let Err(why) = state.filesystem.load_project(path) {
state
.toasts
.error(format!("Error loading the project: {why}"));
} else {
state!().toasts.info(format!(
"Successfully opened {:?}",
state!()
.filesystem
.project_path()
.expect("project not open")
));
}
}));
if let Err(why) = result {
state
.toasts
.error(format!("Error loading the project: {why}"));
} else {
state!().toasts.info(format!(
"Successfully opened {:?}",
state!()
.filesystem
.project_path()
.expect("project not open")
));
}
}));
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/web/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ extern "C" {
#[wasm_bindgen(catch)]
async fn _remove_dir(dir: &web_sys::FileSystemDirectoryHandle) -> Result<JsValue, JsValue>;
pub fn dir_values(dir: &web_sys::FileSystemDirectoryHandle) -> js_sys::AsyncIterator;
async fn _request_permission(handle: &web_sys::FileSystemHandle) -> JsValue;
}

pub async fn show_directory_picker() -> Result<web_sys::FileSystemDirectoryHandle, js_sys::Error> {
Expand All @@ -52,3 +53,7 @@ pub async fn remove_dir(
.map(|o| o.unchecked_into())
.map_err(|e| e.unchecked_into())
}

pub async fn request_permission(handle: &web_sys::FileSystemHandle) -> bool {
_request_permission(handle).await.is_truthy()
}

0 comments on commit 1a2c304

Please sign in to comment.