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.
161161 `/Cache`. A storage worker keeps them in the origin's private file system, writing the
162162 byte ranges each burst changed through OPFS's synchronous handles, which only workers get.
163163 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.
164170- Menus are the kit's own, as on Linux, with the PC's chords and ⌘ for Ctrl on a Mac; the
165171 browser keeps its own window and tab chords. Dialogs are the browser's; Open and Insert
166172 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 {
7979 nodes: BTreeMap<PathBuf, Node>,
8080 /// Paths written, made or removed since the host last asked.
8181 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>>,
8286}
8387
8488thread_local! {
8589 static FILES: RefCell<Files> = RefCell::new(Files {
8690 nodes: BTreeMap::from([(PathBuf::from("/"), Node::Directory)]),
87 changed: BTreeSet::new(),
91 ..Files::default()
8892 });
8993}
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
91109/// What a path holds, as the host keeps it.
92110pub enum Saved {
93111 Directory,
94112 /// The bytes, and when they last changed in milliseconds since 1970.
95113 File(Vec<u8>, f64),
114 /// Nothing: what was there went, with what it held.
115 Gone,
96116}
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.
99120pub fn restore(path: impl AsRef<Path>, saved: Saved) {
100121 let path = normal(path.as_ref());
101122 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), _) => {
105147 Node::File(Data::new(bytes, modified, path.clone(), Some(Vec::new())))
106148 }
107149 };
......@@ -122,7 +164,7 @@ pub enum Change {
122164
123165/// Whether any path changed since `changes` was last called.
124166pub 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())
126168}
127169
128170/// 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 {
754796 }
755797}
756798
799fn mounted(path: &Path) -> bool {
800 with(|files| files.mounts.iter().any(|root| path.starts_with(root)))
801}
802
757803pub 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 })
759825}
760826
827/// A mounted file is durable as the host puts it back, and keeps the version its commit wrote.
761828pub 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)
763834}
764835
765836pub 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 {
371371 }
372372 });
373373 }
374 #[cfg(target_arch = "wasm32")]
375 let mut notice = crate::platform::lock_notice(location);
376 #[cfg(not(target_arch = "wasm32"))]
374377 let mut notice = None;
375378 if let Some(mount) = crate::platform::smb_mount(Path::new(location)) {
376379 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 {
711711 ..Spec::default()
712712 },
713713 );
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 }
721727 ui.close();
722728 }
723729 ui.close();
crates/snowbound/src/web.rs+76-2
......@@ -46,6 +46,14 @@ extern "C" {
4646 pub fn download(name: &str, bytes: &[u8], kind: &str);
4747 #[wasm_bindgen(js_name = pickFiles)]
4848 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>;
4957 #[wasm_bindgen(js_name = dateText)]
5058 fn date_strings(ms: f64) -> Vec<String>;
5159 #[wasm_bindgen(js_name = shortDate)]
......@@ -59,7 +67,9 @@ extern "C" {
5967 #[wasm_bindgen(js_name = openLink)]
6068 fn open_link(url: &str);
6169 /// 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).
6373 #[wasm_bindgen(js_name = storeFiles)]
6474 fn store_files(changes: js_sys::Array);
6575}
......@@ -70,6 +80,8 @@ const CACHE: &str = "/Cache";
7080/// The metric-compatible faces `index.html` fetched, which only this page load holds.
7181const FONTS: &str = "/Fonts";
7282const SETTINGS: &str = "/Settings";
83/// Where folders of the user's given to the browser are mirrored (`glue.js` mounts them).
84const FOLDERS: &str = "/Folders";
7385/// Files chosen to insert, open or paste, kept where the page links them from.
7486const CHOSEN: &str = "/Chosen";
7587/// 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> {
535547
536548/// Asks for notebooks, sections or packages to open; they open once read.
537549pub fn pick_notebook(_: &str) -> Option<PathBuf> {
538 pick_files("open", ".one,.onetoc2,.onepkg");
550 pick_notebook_files();
539551 None
540552}
541553
......@@ -567,6 +579,14 @@ pub fn inform(message: &str, detail: &str) {
567579 alert(message, detail);
568580}
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
570590/// Servers are reached through a relay in a later phase; none is mounted here.
571591pub fn smb_mount(_: &Path) -> Option<crate::library::Mount> {
572592 None
......@@ -681,6 +701,12 @@ pub async fn start(module: JsValue, fonts: Vec<js_sys::Uint8Array>) -> Result<()
681701 canvas.set_width(host(|host| host.size.width));
682702 canvas.set_height(host(|host| host.size.height));
683703 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 }
684710 let state = open(fonts)
685711 .await
686712 .map_err(|error| JsValue::from_str(&error.to_string()))?;
......@@ -736,6 +762,17 @@ fn store() {
736762 };
737763 changes.push(&entry);
738764 }
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 }
739776 if changes.length() > 0 {
740777 store_files(changes);
741778 }
......@@ -936,6 +973,43 @@ pub fn files(purpose: &str, files: js_sys::Array) {
936973 }
937974}
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
9391013/// Writes `[name, bytes]` pairs into `folder` under names not yet taken, returning the paths.
9401014fn keep(files: js_sys::Array, folder: &str) -> Vec<PathBuf> {
9411015 files
crates/snowbound/web/glue.js+152-3
......@@ -132,10 +132,159 @@ export function loadFiles() {
132132 });
133133}
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. */
136138export 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.
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));
139288}
140289
141290export function requestFrame() {
crates/snowbound/web/index.html+4
......@@ -14,6 +14,10 @@
1414 opacity: 0; resize: none; overflow: hidden; white-space: pre; font-size: 16px;
1515 caret-color: transparent; pointer-events: none;
1616 }
17 .reconnect {
18 position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
19 font: 14px system-ui, sans-serif; padding: 6px 14px;
20 }
1721 #status {
1822 position: fixed; inset: 0; display: grid; place-items: center;
1923 font: 15px system-ui, sans-serif; color: #666; pointer-events: none;