| author | |
| committer | |
| log | ce8ac92ba35e5cac71d188d311c0c2c241541692 |
| tree | f39bb612b2ffc55c296818f8fb83cb30ed8495a0 |
| parent | a37218b17ff30f35310862f62150db6f4a6dca04 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Where the browser has the File System Access API (Chromium), Open
Notebook asks for a folder rather than files to copy in: the notebook in
it opens where it is, its handle kept to reach it after a reload, with a
Reconnect button where the browser wants the permission asked again.
notebook::fs mounts such a folder: the page mirrors it and reads what
other apps write to it every few seconds. A browser takes no locks, so a
commit to a section there goes to the page and stays uncertain, as one
whose answer was lost, until the page has written it, provided nothing
else wrote the file since it was read; otherwise the file as it now
stands comes back and the edits go again on top. The sync popup says the
folder is not locked.
Assisted-by: claude-opus-5.57 files changed, 333 insertions(+), 20 deletions(-)
arc/platforms.md+6| ... | ... | @@ -161,6 +161,12 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. |
| 161 | 161 | `/Cache`. A storage worker keeps them in the origin's private file system, writing the |
| 162 | 162 | byte ranges each burst changed through OPFS's synchronous handles, which only workers get. |
| 163 | 163 | One tab at a time holds them. |
| 164 | - Open Notebook, where the browser has the File System Access API (Chromium), opens a folder | |
| 165 | of the user's: mirrored under `/Folders`, its handle kept in IndexedDB, other apps' writes | |
| 166 | read every few seconds. A browser takes no locks, so a commit there stands only once the | |
| 167 | page has written the file and found nothing else wrote it since it was read; until then | |
| 168 | it is uncertain, as one whose answer was lost, and it goes again on top of another app's | |
| 169 | write. The sync popup says the folder isn't locked. | |
| 164 | 170 | - Menus are the kit's own, as on Linux, with the PC's chords and ⌘ for Ctrl on a Mac; the |
| 165 | 171 | browser keeps its own window and tab chords. Dialogs are the browser's; Open and Insert |
| 166 | 172 | ask for files to copy in; printing downloads the PDF. Servers, recording and accessibility |
crates/notebook/src/fs/web.rs+79-8| ... | ... | @@ -79,29 +79,71 @@ struct Files { |
| 79 | 79 | nodes: BTreeMap<PathBuf, Node>, |
| 80 | 80 | /// Paths written, made or removed since the host last asked. |
| 81 | 81 | changed: BTreeSet<PathBuf>, |
| 82 | /// Folders the host mirrors from elsewhere (`mount`). | |
| 83 | mounts: BTreeSet<PathBuf>, | |
| 84 | /// Images committed under a mount, waiting for the host to write them (`committed`). | |
| 85 | committed: BTreeMap<PathBuf, Vec<u8>>, | |
| 82 | 86 | } |
| 83 | 87 | |
| 84 | 88 | thread_local! { |
| 85 | 89 | static FILES: RefCell<Files> = RefCell::new(Files { |
| 86 | 90 | nodes: BTreeMap::from([(PathBuf::from("/"), Node::Directory)]), |
| 87 | changed: BTreeSet::new(), | |
| 91 | ..Files::default() | |
| 88 | 92 | }); |
| 89 | 93 | } |
| 90 | 94 | |
| 95 | /// Mirrors the folder at `root` from somewhere only the host reaches, as a folder on the | |
| 96 | /// user's disk. A commit to a section there goes to the host (`committed`) and stands only | |
| 97 | /// once the host has written it and put the file back (`restore`): until then it is uncertain, | |
| 98 | /// as a commit whose answer was lost, so an edit counts as published only once it is on disk. | |
| 99 | pub fn mount(root: impl AsRef<Path>) { | |
| 100 | let root = normal(root.as_ref()); | |
| 101 | with(|files| files.mounts.insert(root)); | |
| 102 | } | |
| 103 | ||
| 104 | /// The images committed under mounts since the last call, latest per file. | |
| 105 | pub fn committed() -> Vec<(PathBuf, Vec<u8>)> { | |
| 106 | with(|files| std::mem::take(&mut files.committed).into_iter().collect()) | |
| 107 | } | |
| 108 | ||
| 91 | 109 | /// What a path holds, as the host keeps it. |
| 92 | 110 | pub enum Saved { |
| 93 | 111 | Directory, |
| 94 | 112 | /// The bytes, and when they last changed in milliseconds since 1970. |
| 95 | 113 | File(Vec<u8>, f64), |
| 114 | /// Nothing: what was there went, with what it held. | |
| 115 | Gone, | |
| 96 | 116 | } |
| 97 | 117 | |
| 98 | /// Puts what the host kept at `path` back, as it was before the page last closed. | |
| 118 | /// Puts what the host kept at `path` back, as it was before the page last closed or as it | |
| 119 | /// now stands where it is kept. | |
| 99 | 120 | pub fn restore(path: impl AsRef<Path>, saved: Saved) { |
| 100 | 121 | let path = normal(path.as_ref()); |
| 101 | 122 | FILES.with_borrow_mut(|files| { |
| 102 | let node = match saved { | |
| 103 | Saved::Directory => Node::Directory, | |
| 104 | Saved::File(bytes, modified) => { | |
| 123 | let node = match (saved, files.nodes.get(&path)) { | |
| 124 | (Saved::Gone, _) => { | |
| 125 | let gone: Vec<PathBuf> = files | |
| 126 | .nodes | |
| 127 | .range(path.clone()..) | |
| 128 | .take_while(|(each, _)| each.starts_with(&path)) | |
| 129 | .map(|(each, _)| each.clone()) | |
| 130 | .collect(); | |
| 131 | for each in gone { | |
| 132 | if let Some(Node::File(data)) = files.nodes.remove(&each) { | |
| 133 | data.borrow_mut().path = None; | |
| 134 | } | |
| 135 | } | |
| 136 | return; | |
| 137 | } | |
| 138 | (Saved::Directory, _) => Node::Directory, | |
| 139 | (Saved::File(bytes, modified), Some(Node::File(data))) => { | |
| 140 | let mut held = data.borrow_mut(); | |
| 141 | held.bytes = bytes; | |
| 142 | held.modified = modified; | |
| 143 | held.unwritten = Some(Vec::new()); | |
| 144 | return; | |
| 145 | } | |
| 146 | (Saved::File(bytes, modified), _) => { | |
| 105 | 147 | Node::File(Data::new(bytes, modified, path.clone(), Some(Vec::new()))) |
| 106 | 148 | } |
| 107 | 149 | }; |
| ... | ... | @@ -122,7 +164,7 @@ pub enum Change { |
| 122 | 164 | |
| 123 | 165 | /// Whether any path changed since `changes` was last called. |
| 124 | 166 | pub fn changed() -> bool { |
| 125 | FILES.with_borrow(|files| !files.changed.is_empty()) | |
| 167 | FILES.with_borrow(|files| !files.changed.is_empty() || !files.committed.is_empty()) | |
| 126 | 168 | } |
| 127 | 169 | |
| 128 | 170 | /// The paths changed since the last call, each with how; a folder before what it holds. |
| ... | ... | @@ -754,12 +796,41 @@ fn not_committed(error: io::Error) -> CommitError { |
| 754 | 796 | } |
| 755 | 797 | } |
| 756 | 798 | |
| 799 | fn mounted(path: &Path) -> bool { | |
| 800 | with(|files| files.mounts.iter().any(|root| path.starts_with(root))) | |
| 801 | } | |
| 802 | ||
| 757 | 803 | pub fn commit_file(transaction: &Transaction, path: impl AsRef<Path>) -> Result<(), CommitError> { |
| 758 | transaction.commit(&mut writable(path).map_err(not_committed)?) | |
| 804 | let path = normal(path.as_ref()); | |
| 805 | if !mounted(&path) { | |
| 806 | return transaction.commit(&mut writable(&path).map_err(not_committed)?); | |
| 807 | } | |
| 808 | let mut image = File { | |
| 809 | data: Rc::new(RefCell::new(Data { | |
| 810 | bytes: read(&path).map_err(not_committed)?, | |
| 811 | modified: now(), | |
| 812 | path: None, | |
| 813 | unwritten: None, | |
| 814 | })), | |
| 815 | position: 0, | |
| 816 | append: false, | |
| 817 | }; | |
| 818 | transaction.commit(&mut image)?; | |
| 819 | let image = std::mem::take(&mut image.data.borrow_mut().bytes); | |
| 820 | with(|files| files.committed.insert(path, image)); | |
| 821 | Err(CommitError { | |
| 822 | state: CommitState::Unknown, | |
| 823 | error: io::Error::new(ErrorKind::WouldBlock, "Writing the section to its folder"), | |
| 824 | }) | |
| 759 | 825 | } |
| 760 | 826 | |
| 827 | /// A mounted file is durable as the host puts it back, and keeps the version its commit wrote. | |
| 761 | 828 | pub fn confirm_file(path: impl AsRef<Path>, base: &Stamp) -> Result<(), CommitError> { |
| 762 | onestore::confirm(&mut writable(path).map_err(not_committed)?, base) | |
| 829 | let mut file = writable(&path).map_err(not_committed)?; | |
| 830 | if mounted(&normal(path.as_ref())) { | |
| 831 | return base.check(&mut file).map_err(not_committed); | |
| 832 | } | |
| 833 | onestore::confirm(&mut file, base) | |
| 763 | 834 | } |
| 764 | 835 | |
| 765 | 836 | pub fn place_file(path: impl AsRef<Path>, ancestor: [u8; 16], name: &str) -> io::Result<()> { |
crates/snowbound/src/library.rs+3| ... | ... | @@ -371,6 +371,9 @@ impl Library { |
| 371 | 371 | } |
| 372 | 372 | }); |
| 373 | 373 | } |
| 374 | #[cfg(target_arch = "wasm32")] | |
| 375 | let mut notice = crate::platform::lock_notice(location); | |
| 376 | #[cfg(not(target_arch = "wasm32"))] | |
| 374 | 377 | let mut notice = None; |
| 375 | 378 | if let Some(mount) = crate::platform::smb_mount(Path::new(location)) { |
| 376 | 379 | match crate::platform::smb_login(&mount) |
crates/snowbound/src/sync.rs+13-7| ... | ... | @@ -711,13 +711,19 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { |
| 711 | 711 | ..Spec::default() |
| 712 | 712 | }, |
| 713 | 713 | ); |
| 714 | text( | |
| 715 | ui, | |
| 716 | "text", | |
| 717 | &format!("Using the system’s connection. Snowbound couldn’t sign in: {notice}"), | |
| 718 | theme.text_dim, | |
| 719 | ); | |
| 720 | picked.sign_in = ui::button(ui, "sign-in", "Sign In…").clicked; | |
| 714 | // The browser's notice is its folders' lack of locks; it reaches no server to sign in to. | |
| 715 | #[cfg(target_arch = "wasm32")] | |
| 716 | text(ui, "text", notice, theme.text_dim); | |
| 717 | #[cfg(not(target_arch = "wasm32"))] | |
| 718 | { | |
| 719 | text( | |
| 720 | ui, | |
| 721 | "text", | |
| 722 | &format!("Using the system’s connection. Snowbound couldn’t sign in: {notice}"), | |
| 723 | theme.text_dim, | |
| 724 | ); | |
| 725 | picked.sign_in = ui::button(ui, "sign-in", "Sign In…").clicked; | |
| 726 | } | |
| 721 | 727 | ui.close(); |
| 722 | 728 | } |
| 723 | 729 | ui.close(); |
crates/snowbound/src/web.rs+76-2| ... | ... | @@ -46,6 +46,14 @@ extern "C" { |
| 46 | 46 | pub fn download(name: &str, bytes: &[u8], kind: &str); |
| 47 | 47 | #[wasm_bindgen(js_name = pickFiles)] |
| 48 | 48 | fn pick_files(purpose: &str, accept: &str); |
| 49 | /// Asks for a notebook: a folder of the user's where the browser can be given one | |
| 50 | /// (`mounted`), else files to copy in (`files`). | |
| 51 | #[wasm_bindgen(js_name = pickNotebook)] | |
| 52 | fn pick_notebook_files(); | |
| 53 | /// The folders of the user's the browser was given before and may still reach: | |
| 54 | /// `[root, files]`, the files as `loadFiles` gives them. | |
| 55 | #[wasm_bindgen(js_name = loadFolders, catch)] | |
| 56 | async fn load_folders() -> Result<js_sys::Array, JsValue>; | |
| 49 | 57 | #[wasm_bindgen(js_name = dateText)] |
| 50 | 58 | fn date_strings(ms: f64) -> Vec<String>; |
| 51 | 59 | #[wasm_bindgen(js_name = shortDate)] |
| ... | ... | @@ -59,7 +67,9 @@ extern "C" { |
| 59 | 67 | #[wasm_bindgen(js_name = openLink)] |
| 60 | 68 | fn open_link(url: &str); |
| 61 | 69 | /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length, |
| 62 | /// [[offset, bytes], ...]]` a file's new length and the ranges that changed. | |
| 70 | /// [[offset, bytes], ...]]` a file's new length and the ranges that changed; with a | |
| 71 | /// fourth `true`, a section committed under a folder of the user's, written only where | |
| 72 | /// nothing else wrote the file since it was read (`refreshed` answers). | |
| 63 | 73 | #[wasm_bindgen(js_name = storeFiles)] |
| 64 | 74 | fn store_files(changes: js_sys::Array); |
| 65 | 75 | } |
| ... | ... | @@ -70,6 +80,8 @@ const CACHE: &str = "/Cache"; |
| 70 | 80 | /// The metric-compatible faces `index.html` fetched, which only this page load holds. |
| 71 | 81 | const FONTS: &str = "/Fonts"; |
| 72 | 82 | const SETTINGS: &str = "/Settings"; |
| 83 | /// Where folders of the user's given to the browser are mirrored (`glue.js` mounts them). | |
| 84 | const FOLDERS: &str = "/Folders"; | |
| 73 | 85 | /// Files chosen to insert, open or paste, kept where the page links them from. |
| 74 | 86 | const CHOSEN: &str = "/Chosen"; |
| 75 | 87 | /// How long changed files wait to be written out, so a burst of edits writes once. |
| ... | ... | @@ -535,7 +547,7 @@ pub fn pick_file(_: &str, types: &[&str]) -> Option<PathBuf> { |
| 535 | 547 | |
| 536 | 548 | /// Asks for notebooks, sections or packages to open; they open once read. |
| 537 | 549 | pub fn pick_notebook(_: &str) -> Option<PathBuf> { |
| 538 | pick_files("open", ".one,.onetoc2,.onepkg"); | |
| 550 | pick_notebook_files(); | |
| 539 | 551 | None |
| 540 | 552 | } |
| 541 | 553 | |
| ... | ... | @@ -567,6 +579,14 @@ pub fn inform(message: &str, detail: &str) { |
| 567 | 579 | alert(message, detail); |
| 568 | 580 | } |
| 569 | 581 | |
| 582 | /// What the sync popup says of a notebook in a folder of the user's, which the browser | |
| 583 | /// writes without the locks OneNote takes. | |
| 584 | pub fn lock_notice(location: &str) -> Option<String> { | |
| 585 | location.starts_with(&format!("{FOLDERS}/")).then(|| { | |
| 586 | "This folder isn’t locked while you edit. Edit each section in one app at a time.".into() | |
| 587 | }) | |
| 588 | } | |
| 589 | ||
| 570 | 590 | /// Servers are reached through a relay in a later phase; none is mounted here. |
| 571 | 591 | pub fn smb_mount(_: &Path) -> Option<crate::library::Mount> { |
| 572 | 592 | None |
| ... | ... | @@ -681,6 +701,12 @@ pub async fn start(module: JsValue, fonts: Vec<js_sys::Uint8Array>) -> Result<() |
| 681 | 701 | canvas.set_width(host(|host| host.size.width)); |
| 682 | 702 | canvas.set_height(host(|host| host.size.height)); |
| 683 | 703 | restore(load_files().await?); |
| 704 | for folder in load_folders().await?.iter() { | |
| 705 | let folder = js_sys::Array::from(&folder); | |
| 706 | let root = folder.get(0).as_string().unwrap_or_default(); | |
| 707 | notebook::fs::mount(&root); | |
| 708 | restore(folder.get(1).into()); | |
| 709 | } | |
| 684 | 710 | let state = open(fonts) |
| 685 | 711 | .await |
| 686 | 712 | .map_err(|error| JsValue::from_str(&error.to_string()))?; |
| ... | ... | @@ -736,6 +762,17 @@ fn store() { |
| 736 | 762 | }; |
| 737 | 763 | changes.push(&entry); |
| 738 | 764 | } |
| 765 | for (path, image) in notebook::fs::committed() { | |
| 766 | changes.push(&js_sys::Array::of4( | |
| 767 | &JsValue::from_str(&path.to_string_lossy()), | |
| 768 | &(image.len() as f64).into(), | |
| 769 | &js_sys::Array::of1(&js_sys::Array::of2( | |
| 770 | &0.into(), | |
| 771 | &js_sys::Uint8Array::from(image.as_slice()), | |
| 772 | )), | |
| 773 | &true.into(), | |
| 774 | )); | |
| 775 | } | |
| 739 | 776 | if changes.length() > 0 { |
| 740 | 777 | store_files(changes); |
| 741 | 778 | } |
| ... | ... | @@ -936,6 +973,43 @@ pub fn files(purpose: &str, files: js_sys::Array) { |
| 936 | 973 | } |
| 937 | 974 | } |
| 938 | 975 | |
| 976 | /// A folder of the user's given to the browser, mirrored at `root` with `files` as | |
| 977 | /// `loadFiles` gives them, opened as a notebook. | |
| 978 | #[wasm_bindgen] | |
| 979 | pub fn mounted(root: String, files: js_sys::Array) { | |
| 980 | notebook::fs::mount(&root); | |
| 981 | restore(files); | |
| 982 | send(UserEvent::Open(vec![root.into()])); | |
| 983 | } | |
| 984 | ||
| 985 | /// A path under a folder of the user's as it now stands there, after Snowbound wrote it or | |
| 986 | /// after something else did: a file's bytes, null for a folder, undefined where it went. The | |
| 987 | /// sections' synchronization takes it up. | |
| 988 | #[wasm_bindgen] | |
| 989 | pub fn refreshed(path: String, bytes: JsValue, modified: f64) { | |
| 990 | notebook::fs::restore( | |
| 991 | path, | |
| 992 | if bytes.is_undefined() { | |
| 993 | notebook::fs::Saved::Gone | |
| 994 | } else if bytes.is_null() { | |
| 995 | notebook::fs::Saved::Directory | |
| 996 | } else { | |
| 997 | notebook::fs::Saved::File(js_sys::Uint8Array::new(&bytes).to_vec(), modified) | |
| 998 | }, | |
| 999 | ); | |
| 1000 | send(UserEvent::Then(Box::new(|state| { | |
| 1001 | state.publish_now(Duration::ZERO); | |
| 1002 | for background in state | |
| 1003 | .notebooks | |
| 1004 | .iter() | |
| 1005 | .filter_map(|library| library.background.as_ref()) | |
| 1006 | { | |
| 1007 | background.wake(); | |
| 1008 | } | |
| 1009 | Ok(()) | |
| 1010 | }))); | |
| 1011 | } | |
| 1012 | ||
| 939 | 1013 | /// Writes `[name, bytes]` pairs into `folder` under names not yet taken, returning the paths. |
| 940 | 1014 | fn keep(files: js_sys::Array, folder: &str) -> Vec<PathBuf> { |
| 941 | 1015 | files |
crates/snowbound/web/glue.js+152-3| ... | ... | @@ -132,10 +132,159 @@ export function loadFiles() { |
| 132 | 132 | }); |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | /** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, length, ranges]` entries. */ | |
| 135 | /** Writes `[path]` (removed), `[path, null]` (a folder) and `[path, length, ranges]` entries; | |
| 136 | * those under a folder of the user's go there, a committed section only where nothing else | |
| 137 | * wrote it since it was read. */ | |
| 136 | 138 | export function storeFiles(changes) { |
| 137 | const buffers = changes.flatMap(([, , ranges]) => (ranges ?? []).map(([, bytes]) => bytes.buffer)); | |
| 138 | storage.postMessage({ kind: "store", changes }, buffers); | |
| 139 | const kept = changes.filter(([path]) => !folderOf(path)); | |
| 140 | for (const change of changes) if (folderOf(change[0])) serially(() => writeFolder(change)); | |
| 141 | if (kept.length) | |
| 142 | storage.postMessage( | |
| 143 | { kind: "store", changes: kept }, | |
| 144 | kept.flatMap(([, , ranges]) => (ranges ?? []).map(([, bytes]) => bytes.buffer)), | |
| 145 | ); | |
| 146 | } | |
| 147 | ||
| 148 | // Folders of the user's, through the File System Access API (Chromium): mirrored in | |
| 149 | // `notebook::fs` under FOLDERS, their handles kept in IndexedDB to reach them again. | |
| 150 | const FOLDERS = "/Folders"; | |
| 151 | const folders = new Map(); | |
| 152 | let writing = Promise.resolve(); | |
| 153 | const serially = (work) => | |
| 154 | (writing = writing.then(work).catch((error) => console.error("Writing to the folder", error))); | |
| 155 | const folderOf = (path) => | |
| 156 | [...folders.keys()].find((root) => path === root || path.startsWith(`${root}/`)); | |
| 157 | const stamp = (file) => `${file.size}:${file.lastModified}`; | |
| 158 | ||
| 159 | function kept(mode, act) { | |
| 160 | return new Promise((resolve, reject) => { | |
| 161 | const open = indexedDB.open("snowbound-folders", 1); | |
| 162 | open.onupgradeneeded = () => open.result.createObjectStore("folders", { keyPath: "root" }); | |
| 163 | open.onerror = () => reject(open.error); | |
| 164 | open.onsuccess = () => { | |
| 165 | const request = act(open.result.transaction("folders", mode).objectStore("folders")); | |
| 166 | request.onsuccess = () => resolve(request.result); | |
| 167 | request.onerror = () => reject(request.error); | |
| 168 | }; | |
| 169 | }); | |
| 170 | } | |
| 171 | ||
| 172 | /** Every file and folder below `dir`, at `path`: `[path, File or null]`. */ | |
| 173 | async function walk(dir, path, out = []) { | |
| 174 | for await (const [name, entry] of dir.entries()) { | |
| 175 | const at = `${path}/${name}`; | |
| 176 | if (entry.kind === "directory") { | |
| 177 | out.push([at, null]); | |
| 178 | await walk(entry, at, out); | |
| 179 | } else out.push([at, await entry.getFile()]); | |
| 180 | } | |
| 181 | return out; | |
| 182 | } | |
| 183 | ||
| 184 | /** Mirrors the folder `handle` at `root`: its files as `loadFiles` gives them. */ | |
| 185 | async function mirror(root, handle) { | |
| 186 | const known = new Map(); | |
| 187 | folders.set(root, { handle, known }); | |
| 188 | const files = [ | |
| 189 | [FOLDERS, null, 0], | |
| 190 | [root, null, 0], | |
| 191 | ]; | |
| 192 | for (const [path, file] of await walk(handle, root)) { | |
| 193 | known.set(path, file ? stamp(file) : "folder"); | |
| 194 | files.push(file ? [path, new Uint8Array(await file.arrayBuffer()), file.lastModified] : [path, null, 0]); | |
| 195 | } | |
| 196 | return files; | |
| 197 | } | |
| 198 | ||
| 199 | /** The folders given before that the browser may still reach: `[root, files]`. Each other | |
| 200 | * folder offers to reconnect, which needs a click. */ | |
| 201 | export async function loadFolders() { | |
| 202 | const out = []; | |
| 203 | for (const { root, handle } of await kept("readonly", (store) => store.getAll())) { | |
| 204 | if ((await handle.queryPermission({ mode: "readwrite" })) === "granted") | |
| 205 | out.push([root, await mirror(root, handle)]); | |
| 206 | else { | |
| 207 | const button = Object.assign(document.createElement("button"), { | |
| 208 | className: "reconnect", | |
| 209 | textContent: `Reconnect “${handle.name}”`, | |
| 210 | }); | |
| 211 | button.onclick = async () => { | |
| 212 | if ((await handle.requestPermission({ mode: "readwrite" })) === "granted") location.reload(); | |
| 213 | }; | |
| 214 | document.body.append(button); | |
| 215 | } | |
| 216 | } | |
| 217 | // Other apps' writes reach the page once they land, as a watch would report them. | |
| 218 | setInterval(() => serially(look), 3000); | |
| 219 | return out; | |
| 220 | } | |
| 221 | ||
| 222 | /** Hands the page what changed in each folder since it was last read. */ | |
| 223 | async function look() { | |
| 224 | for (const [root, folder] of folders) { | |
| 225 | const seen = new Map(); | |
| 226 | for (const [path, file] of await walk(folder.handle, root)) { | |
| 227 | seen.set(path, file ? stamp(file) : "folder"); | |
| 228 | if (folder.known.get(path) !== seen.get(path)) | |
| 229 | wasm.refreshed(path, file ? new Uint8Array(await file.arrayBuffer()) : null, file?.lastModified ?? 0); | |
| 230 | } | |
| 231 | for (const path of folder.known.keys()) if (!seen.has(path)) wasm.refreshed(path, undefined, 0); | |
| 232 | folder.known = seen; | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | async function locate(path) { | |
| 237 | const root = folderOf(path); | |
| 238 | const parts = path.slice(root.length).split("/").filter(Boolean); | |
| 239 | let dir = folders.get(root).handle; | |
| 240 | for (const name of parts.slice(0, -1)) dir = await dir.getDirectoryHandle(name, { create: true }); | |
| 241 | return [dir, parts.at(-1), folders.get(root)]; | |
| 242 | } | |
| 243 | ||
| 244 | async function writeFolder([path, length, ranges, committed]) { | |
| 245 | if (folders.has(path)) return; | |
| 246 | const [dir, name, folder] = await locate(path); | |
| 247 | if (length === undefined) { | |
| 248 | await dir.removeEntry(name, { recursive: true }).catch(() => {}); | |
| 249 | for (const known of [...folder.known.keys()]) | |
| 250 | if (known === path || known.startsWith(`${path}/`)) folder.known.delete(known); | |
| 251 | return; | |
| 252 | } | |
| 253 | if (length === null) { | |
| 254 | await dir.getDirectoryHandle(name, { create: true }); | |
| 255 | folder.known.set(path, "folder"); | |
| 256 | return; | |
| 257 | } | |
| 258 | const handle = await dir.getFileHandle(name, { create: true }); | |
| 259 | const before = await handle.getFile(); | |
| 260 | if (committed && folder.known.get(path) !== stamp(before)) { | |
| 261 | // Another app wrote the section since it was read: the commit stands on what it found | |
| 262 | // no longer, so the page takes the file as it is and its edits go again on top. | |
| 263 | folder.known.set(path, stamp(before)); | |
| 264 | return wasm.refreshed(path, new Uint8Array(await before.arrayBuffer()), before.lastModified); | |
| 265 | } | |
| 266 | const writable = await handle.createWritable({ keepExistingData: !committed }); | |
| 267 | for (const [offset, bytes] of ranges) await writable.write({ type: "write", position: offset, data: bytes }); | |
| 268 | await writable.truncate(length); | |
| 269 | await writable.close(); | |
| 270 | const after = await handle.getFile(); | |
| 271 | folder.known.set(path, stamp(after)); | |
| 272 | if (committed) wasm.refreshed(path, ranges[0][1], after.lastModified); | |
| 273 | } | |
| 274 | ||
| 275 | /** Asks for a notebook: its folder where the browser can be given one, else files to copy in. */ | |
| 276 | export function pickNotebook() { | |
| 277 | if (!window.showDirectoryPicker) return pickFiles("open", ".one,.onetoc2,.onepkg"); | |
| 278 | showDirectoryPicker({ id: "notebook", mode: "readwrite" }) | |
| 279 | .then(async (handle) => { | |
| 280 | for (const [root, folder] of folders) | |
| 281 | if (await folder.handle.isSameEntry(handle)) return wasm.mounted(root, []); | |
| 282 | let root = `${FOLDERS}/${handle.name}`; | |
| 283 | for (let number = 2; folders.has(root); number++) root = `${FOLDERS}/${handle.name} ${number}`; | |
| 284 | await kept("readwrite", (store) => store.put({ root, handle })); | |
| 285 | wasm.mounted(root, await mirror(root, handle)); | |
| 286 | }) | |
| 287 | .catch((error) => error.name === "AbortError" || console.error("Opening the folder", error)); | |
| 139 | 288 | } |
| 140 | 289 | |
| 141 | 290 | export function requestFrame() { |
crates/snowbound/web/index.html+4| ... | ... | @@ -14,6 +14,10 @@ |
| 14 | 14 | opacity: 0; resize: none; overflow: hidden; white-space: pre; font-size: 16px; |
| 15 | 15 | caret-color: transparent; pointer-events: none; |
| 16 | 16 | } |
| 17 | .reconnect { | |
| 18 | position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); | |
| 19 | font: 14px system-ui, sans-serif; padding: 6px 14px; | |
| 20 | } | |
| 17 | 21 | #status { |
| 18 | 22 | position: fixed; inset: 0; display: grid; place-items: center; |
| 19 | 23 | font: 15px system-ui, sans-serif; color: #666; pointer-events: none; |