authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 17:55:20-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 19:43:20-07:00
logce8ac92ba35e5cac71d188d311c0c2c241541692
treef39bb612b2ffc55c296818f8fb83cb30ed8495a0
parenta37218b17ff30f35310862f62150db6f4a6dca04
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the web build opens notebook folders on the computer

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.5

7 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,6 +161,12 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
161 `/Cache`. A storage worker keeps them in the origin's private file system, writing the161 `/Cache`. A storage worker keeps them in the origin's private file system, writing the
162 byte ranges each burst changed through OPFS's synchronous handles, which only workers get.162 byte ranges each burst changed through OPFS's synchronous handles, which only workers get.
163 One tab at a time holds them.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- Menus are the kit's own, as on Linux, with the PC's chords and ⌘ for Ctrl on a Mac; the170- Menus are the kit's own, as on Linux, with the PC's chords and ⌘ for Ctrl on a Mac; the
165 browser keeps its own window and tab chords. Dialogs are the browser's; Open and Insert171 browser keeps its own window and tab chords. Dialogs are the browser's; Open and Insert
166 ask for files to copy in; printing downloads the PDF. Servers, recording and accessibility172 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,29 +79,71 @@ struct Files {
79 nodes: BTreeMap<PathBuf, Node>,79 nodes: BTreeMap<PathBuf, Node>,
80 /// Paths written, made or removed since the host last asked.80 /// Paths written, made or removed since the host last asked.
81 changed: BTreeSet<PathBuf>,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}
8387
84thread_local! {88thread_local! {
85 static FILES: RefCell<Files> = RefCell::new(Files {89 static FILES: RefCell<Files> = RefCell::new(Files {
86 nodes: BTreeMap::from([(PathBuf::from("/"), Node::Directory)]),90 nodes: BTreeMap::from([(PathBuf::from("/"), Node::Directory)]),
87 changed: BTreeSet::new(),91 ..Files::default()
88 });92 });
89}93}
9094
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.
99pub 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.
105pub fn committed() -> Vec<(PathBuf, Vec<u8>)> {
106 with(|files| std::mem::take(&mut files.committed).into_iter().collect())
107}
108
91/// What a path holds, as the host keeps it.109/// What a path holds, as the host keeps it.
92pub enum Saved {110pub enum Saved {
93 Directory,111 Directory,
94 /// The bytes, and when they last changed in milliseconds since 1970.112 /// The bytes, and when they last changed in milliseconds since 1970.
95 File(Vec<u8>, f64),113 File(Vec<u8>, f64),
114 /// Nothing: what was there went, with what it held.
115 Gone,
96}116}
97117
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.
99pub fn restore(path: impl AsRef<Path>, saved: Saved) {120pub fn restore(path: impl AsRef<Path>, saved: Saved) {
100 let path = normal(path.as_ref());121 let path = normal(path.as_ref());
101 FILES.with_borrow_mut(|files| {122 FILES.with_borrow_mut(|files| {
102 let node = match saved {123 let node = match (saved, files.nodes.get(&path)) {
103 Saved::Directory => Node::Directory,124 (Saved::Gone, _) => {
104 Saved::File(bytes, modified) => {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 Node::File(Data::new(bytes, modified, path.clone(), Some(Vec::new())))147 Node::File(Data::new(bytes, modified, path.clone(), Some(Vec::new())))
106 }148 }
107 };149 };
...@@ -122,7 +164,7 @@ pub enum Change {...@@ -122,7 +164,7 @@ pub enum Change {
122164
123/// Whether any path changed since `changes` was last called.165/// Whether any path changed since `changes` was last called.
124pub fn changed() -> bool {166pub 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}
127169
128/// The paths changed since the last call, each with how; a folder before what it holds.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,12 +796,41 @@ fn not_committed(error: io::Error) -> CommitError {
754 }796 }
755}797}
756798
799fn mounted(path: &Path) -> bool {
800 with(|files| files.mounts.iter().any(|root| path.starts_with(root)))
801}
802
757pub fn commit_file(transaction: &Transaction, path: impl AsRef<Path>) -> Result<(), CommitError> {803pub 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}
760826
827/// A mounted file is durable as the host puts it back, and keeps the version its commit wrote.
761pub fn confirm_file(path: impl AsRef<Path>, base: &Stamp) -> Result<(), CommitError> {828pub 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}
764835
765pub fn place_file(path: impl AsRef<Path>, ancestor: [u8; 16], name: &str) -> io::Result<()> {836pub 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,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 let mut notice = None;377 let mut notice = None;
375 if let Some(mount) = crate::platform::smb_mount(Path::new(location)) {378 if let Some(mount) = crate::platform::smb_mount(Path::new(location)) {
376 match crate::platform::smb_login(&mount)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,13 +711,19 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
711 ..Spec::default()711 ..Spec::default()
712 },712 },
713 );713 );
714 text(714 // The browser's notice is its folders' lack of locks; it reaches no server to sign in to.
715 ui,715 #[cfg(target_arch = "wasm32")]
716 "text",716 text(ui, "text", notice, theme.text_dim);
717 &format!("Using the system’s connection. Snowbound couldn’t sign in: {notice}"),717 #[cfg(not(target_arch = "wasm32"))]
718 theme.text_dim,718 {
719 );719 text(
720 picked.sign_in = ui::button(ui, "sign-in", "Sign In…").clicked;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 ui.close();727 ui.close();
722 }728 }
723 ui.close();729 ui.close();
crates/snowbound/src/web.rs+76-2
...@@ -46,6 +46,14 @@ extern "C" {...@@ -46,6 +46,14 @@ extern "C" {
46 pub fn download(name: &str, bytes: &[u8], kind: &str);46 pub fn download(name: &str, bytes: &[u8], kind: &str);
47 #[wasm_bindgen(js_name = pickFiles)]47 #[wasm_bindgen(js_name = pickFiles)]
48 fn pick_files(purpose: &str, accept: &str);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 #[wasm_bindgen(js_name = dateText)]57 #[wasm_bindgen(js_name = dateText)]
50 fn date_strings(ms: f64) -> Vec<String>;58 fn date_strings(ms: f64) -> Vec<String>;
51 #[wasm_bindgen(js_name = shortDate)]59 #[wasm_bindgen(js_name = shortDate)]
...@@ -59,7 +67,9 @@ extern "C" {...@@ -59,7 +67,9 @@ extern "C" {
59 #[wasm_bindgen(js_name = openLink)]67 #[wasm_bindgen(js_name = openLink)]
60 fn open_link(url: &str);68 fn open_link(url: &str);
61 /// Writes changes out: `[path]` removed, `[path, null]` a folder, and `[path, length,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 #[wasm_bindgen(js_name = storeFiles)]73 #[wasm_bindgen(js_name = storeFiles)]
64 fn store_files(changes: js_sys::Array);74 fn store_files(changes: js_sys::Array);
65}75}
...@@ -70,6 +80,8 @@ const CACHE: &str = "/Cache";...@@ -70,6 +80,8 @@ const CACHE: &str = "/Cache";
70/// The metric-compatible faces `index.html` fetched, which only this page load holds.80/// The metric-compatible faces `index.html` fetched, which only this page load holds.
71const FONTS: &str = "/Fonts";81const FONTS: &str = "/Fonts";
72const SETTINGS: &str = "/Settings";82const SETTINGS: &str = "/Settings";
83/// Where folders of the user's given to the browser are mirrored (`glue.js` mounts them).
84const FOLDERS: &str = "/Folders";
73/// Files chosen to insert, open or paste, kept where the page links them from.85/// Files chosen to insert, open or paste, kept where the page links them from.
74const CHOSEN: &str = "/Chosen";86const CHOSEN: &str = "/Chosen";
75/// How long changed files wait to be written out, so a burst of edits writes once.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,7 +547,7 @@ pub fn pick_file(_: &str, types: &[&str]) -> Option<PathBuf> {
535547
536/// Asks for notebooks, sections or packages to open; they open once read.548/// Asks for notebooks, sections or packages to open; they open once read.
537pub fn pick_notebook(_: &str) -> Option<PathBuf> {549pub fn pick_notebook(_: &str) -> Option<PathBuf> {
538 pick_files("open", ".one,.onetoc2,.onepkg");550 pick_notebook_files();
539 None551 None
540}552}
541553
...@@ -567,6 +579,14 @@ pub fn inform(message: &str, detail: &str) {...@@ -567,6 +579,14 @@ pub fn inform(message: &str, detail: &str) {
567 alert(message, detail);579 alert(message, detail);
568}580}
569581
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.
584pub 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/// Servers are reached through a relay in a later phase; none is mounted here.590/// Servers are reached through a relay in a later phase; none is mounted here.
571pub fn smb_mount(_: &Path) -> Option<crate::library::Mount> {591pub fn smb_mount(_: &Path) -> Option<crate::library::Mount> {
572 None592 None
...@@ -681,6 +701,12 @@ pub async fn start(module: JsValue, fonts: Vec<js_sys::Uint8Array>) -> Result<()...@@ -681,6 +701,12 @@ pub async fn start(module: JsValue, fonts: Vec<js_sys::Uint8Array>) -> Result<()
681 canvas.set_width(host(|host| host.size.width));701 canvas.set_width(host(|host| host.size.width));
682 canvas.set_height(host(|host| host.size.height));702 canvas.set_height(host(|host| host.size.height));
683 restore(load_files().await?);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 let state = open(fonts)710 let state = open(fonts)
685 .await711 .await
686 .map_err(|error| JsValue::from_str(&error.to_string()))?;712 .map_err(|error| JsValue::from_str(&error.to_string()))?;
...@@ -736,6 +762,17 @@ fn store() {...@@ -736,6 +762,17 @@ fn store() {
736 };762 };
737 changes.push(&entry);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 if changes.length() > 0 {776 if changes.length() > 0 {
740 store_files(changes);777 store_files(changes);
741 }778 }
...@@ -936,6 +973,43 @@ pub fn files(purpose: &str, files: js_sys::Array) {...@@ -936,6 +973,43 @@ pub fn files(purpose: &str, files: js_sys::Array) {
936 }973 }
937}974}
938975
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]
979pub 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]
989pub 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/// Writes `[name, bytes]` pairs into `folder` under names not yet taken, returning the paths.1013/// Writes `[name, bytes]` pairs into `folder` under names not yet taken, returning the paths.
940fn keep(files: js_sys::Array, folder: &str) -> Vec<PathBuf> {1014fn keep(files: js_sys::Array, folder: &str) -> Vec<PathBuf> {
941 files1015 files
crates/snowbound/web/glue.js+152-3
...@@ -132,10 +132,159 @@ export function loadFiles() {...@@ -132,10 +132,159 @@ export function loadFiles() {
132 });132 });
133}133}
134134
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. */
136export function storeFiles(changes) {138export function storeFiles(changes) {
137 const buffers = changes.flatMap(([, , ranges]) => (ranges ?? []).map(([, bytes]) => bytes.buffer));139 const kept = changes.filter(([path]) => !folderOf(path));
138 storage.postMessage({ kind: "store", changes }, buffers);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.
150const FOLDERS = "/Folders";
151const folders = new Map();
152let writing = Promise.resolve();
153const serially = (work) =>
154 (writing = writing.then(work).catch((error) => console.error("Writing to the folder", error)));
155const folderOf = (path) =>
156 [...folders.keys()].find((root) => path === root || path.startsWith(`${root}/`));
157const stamp = (file) => `${file.size}:${file.lastModified}`;
158
159function 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]`. */
173async 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. */
185async 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. */
201export 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. */
223async 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
236async 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
244async 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. */
276export 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}
140289
141export function requestFrame() {290export function requestFrame() {
crates/snowbound/web/index.html+4
...@@ -14,6 +14,10 @@...@@ -14,6 +14,10 @@
14 opacity: 0; resize: none; overflow: hidden; white-space: pre; font-size: 16px;14 opacity: 0; resize: none; overflow: hidden; white-space: pre; font-size: 16px;
15 caret-color: transparent; pointer-events: none;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 #status {21 #status {
18 position: fixed; inset: 0; display: grid; place-items: center;22 position: fixed; inset: 0; display: grid; place-items: center;
19 font: 15px system-ui, sans-serif; color: #666; pointer-events: none;23 font: 15px system-ui, sans-serif; color: #666; pointer-events: none;