diff --git a/CLAUDE.md b/CLAUDE.md index d9929d4..d126984 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,11 +23,23 @@ Notes live in **nested folders** — real subdirectories on disk; a note's id is root `Attachments/` folder, referenced root-relatively, resolved to `blob:` URLs only at display time). Both work across all three backends. +Every opened folder/store is a **workspace**: an IndexedDB registry (`src/storage/workspaceRegistry.ts`) +remembers them all with recency, feeding the orb menu's "Open Recent" submenu, the **⌃R switcher +dialog**, and — desktop only — **one native window per workspace** (several at once; ⌘-click a recent +or ⌘↵ in the switcher). Per-workspace UI layout (sidebar/rail/selected folder) lives under +workspace-namespaced localStorage keys; the sidecar metadata is per-folder anyway. + Notes also move between backends via `.md` export/import (`src/storage/transfer.ts`). The Rust shell lives in `src-tauri/` (only `src-tauri/src/lib.rs` carries app code: `notes_*` + `attachment_*` fs -commands, the folder ops, and `reveal_path`; it also registers the **updater** + **process** plugins, -sets a theme-aware native window background (anti-flash), and builds a **custom app menu** whose macOS -"About" item emits `menu:about` so the frontend can open its own `AboutDialog`). +commands, the folder ops, `reveal_path`, and the **workspace-window commands** — a label→workspace map +powering `window_workspace`/`set_window_workspace`/`focus_workspace_window`/`open_workspace_window` +(`ws-N` windows cloned from the main window's config); it also registers the **updater** + **process** +plugins, sets a theme-aware native window background (anti-flash, factored as `apply_macos_chrome` and +applied to every window), and builds a **custom app menu** whose macOS "About" item emits `menu:about` +to the FOCUSED window so the frontend can open its own `AboutDialog`). **Close flow:** the main window +hides on ⌘W (Rust-side, macOS convention) while `ws-N` windows really close — `useNotes`'s +`onCloseRequested` handler flushes pending edits then calls `preventDefault()` ONLY for `main`; the JS +wrapper's `destroy()` needs the `core:window:allow-destroy` capability, so those two must stay in sync. The desktop app ships **in-app auto-update** via the official Tauri 2 updater, delivered through GitHub Releases (`src/hooks/useAppUpdater.ts`; cut a release with the `/release` runbook in `.claude/skills/release`). @@ -122,10 +134,14 @@ Key modules: backend; the way to get plain files out of in-browser storage and migrate between backends. Preserves folder structure (incl. deliberately-empty folders, via a `.gnkeep` marker) and bundles `Attachments/` bytes both ways. -- `src/storage/handlePersistence.ts` — remembers the chosen backend in IndexedDB so reloads restore - it: a `FileSystemDirectoryHandle` (web, per-session permission re-grant) or a plain folder-path - string (`tauri-fs`, no re-grant — the OS governs access). Each `tx()` closes the connection on - complete / error / abort. +- `src/storage/workspaceRegistry.ts` — the workspace registry (IndexedDB `gravity-notes` v2): every + opened workspace as a `WorkspaceEntry` (`indexeddb` | `tauri:` | `fsa:`; a + `FileSystemDirectoryHandle` structured-clones inside the entry, `tauri-fs` keeps a path string) plus + a last-active pointer for launch restore. Lazily migrates the v1 single-choice keys into a + deterministic entry (`fsa:legacy`) so a StrictMode/two-window double-run is idempotent. ⚠️ This must + stay the ONLY module opening that database — a second module opening v1 would throw `VersionError` + after the upgrade. Each `tx()` closes the connection on complete / error / abort (short-lived + connections are also why cross-window v1→v2 upgrades can't block). - `src/storage/metadata.ts` — the per-store metadata (`.gravity-notes.json` sidecar for the FS store): tolerant `parseMetadata`, pure transforms (`withPinToggled`, `withActive`, `reconcile`, …), and `orderNotes` (pins first, then the active sort). The `pinned` set holds both note ids and folder @@ -136,13 +152,23 @@ Key modules: visible image's URL (subscribed entries are never evicted) and get notified if it's re-seeded; `peek` is pure (no LRU touch), `resolve` touches. The stored Markdown always keeps the root-relative ref, never a blob URL. -- `src/hooks/useNotesStorage.ts` — first-run storage choice + permission lifecycle (state machine: - `loading`/`choosing`/`needs-permission`/`ready`); yields a ready `NoteStore`. Detects the Tauri - shell (`__TAURI_INTERNALS__`): there `pickFolder()` uses the native dialog and the `tauri-fs` - bootstrap goes straight to `ready` (no `needs-permission`). `supportsFolders` (= native app OR - browser FSA) drives whether the folder option is offered. Bootstrap is `try/catch`-guarded and bails - (via `interactedRef`) once the user chooses, so a slow IndexedDB read can't clobber it. Back-compat: - a stored folder handle with no backend flag is treated as filesystem. +- `src/hooks/useNotesStorage.ts` — the workspace lifecycle (state machine: + `loading`/`choosing`/`needs-permission`/`ready`); yields a ready `NoteStore` plus the recents list. + Bootstrap order: the shell's per-window assignment (`invoke('window_workspace')`, set before a + `ws-N` window's page loads) → the last-active pointer → `choosing`. `openWorkspace(id)` switches in + place (probe-guarded; desktop first asks `focus_workspace_window` so a workspace already shown + elsewhere is focused, not duplicated; a failed switch leaves the current workspace mounted); + `openInNewWindow(id)` invokes `open_workspace_window`; activation touches the registry, registers + the window's workspace, and sets the native title. Detects the Tauri shell (`__TAURI_INTERNALS__`): + there `pickFolder()` uses the native dialog and `tauri-fs` opens go straight to `ready` (no + `needs-permission`; an FSA _switch_ tries `requestPermission` inline — it runs off a gesture — + before falling back to the grant gate). `supportsFolders` (= native app OR browser FSA) drives + whether the folder option is offered. Every async action claims an `opSeq` ticket and bails once + superseded (including the detached post-activation writes — recency, `set_window_workspace`, native + title — so a rapid A→B switch can't restore A next launch or register A for a window showing B), so a + slow bootstrap read can't clobber a user choice and rapid switches can't interleave. `reset()` + returns to the gate but DOES `clearLastActive()` (so "choose different storage" sticks across a + relaunch) while keeping the registry. - `src/hooks/useNotes.ts` — note list, selection, **debounced autosave** (500 ms), and **conflict detection**. Editing is deliberately decoupled from React state: keystrokes flow into a ref + timer, not `setState`, so the markdown editor instance is never re-created mid-typing. Pending edits are @@ -161,23 +187,45 @@ Key modules: open note's backlinks (invert once via `useCorpus.linksById`, then snippet + sort lazily per open note); and the global keyboard shortcuts (driven by the `SHORTCUTS` descriptor in `src/shortcuts.ts`, which the help dialog also renders from). Punctuation chords match by `event.code` (e.g. `⌘⇧;` → - `Semicolon`), since the shifted `event.key` differs. `useDebouncedValue` debounces the query (120 ms) - only above a 500-note vault (wired in `Workspace`). + `Semicolon`), since the shifted `event.key` differs. `useShortcuts` skips ALL global chords while a + `[role="dialog"]` modal is open (the dialog owns the keyboard — so ⌘N can't create a note behind the + ⌃R switcher). `useDebouncedValue` debounces the query (120 ms) only above a 500-note vault (wired in + `Workspace`). +- `src/hooks/useListboxNav.ts` — the shared filter-over-listbox keyboard model for the ⌃R workspace + switcher AND the ⌘⇧M move-to picker: a DOCUMENT-level keydown listener while open (↑/↓ skip-disabled, + ↵ commit, ⌘⌫ remove, Esc close), a range/disabled clamp on list change, and scroll-into-view. It's + document-level (not input-scoped `onKeyDown`) + paired with `initialFocus={inputRef}` because + Gravity's `Dialog` focus-manager can park focus on the dialog container, where an input handler goes + silent. Callers own filtering + rendering + pre-highlight seeding; `highlightMatch` (shared) marks + the filter match. - `src/hooks/useAppUpdater.ts` — in-app auto-update (macOS desktop) over the Tauri updater/process plugins: a small state machine (check → available → downloading → installed / restart-required / error, with retry). `isTauri`-guarded, all Tauri APIs via dynamic `import()`, so it no-ops and stays out of the web bundle. `Workspace` runs a silent check on launch (production only) → toast; `TopBar` adds a manual "Check for Updates…" item; `UpdateDialog` renders the flow. -- `src/components/` — `FolderGate` (first-run storage choice + folder re-permission gate), `Workspace` - (top bar + layout + nav wiring; takes the `NoteStore`, owns export/import), `TopBar` (nvALT search - box + storage menu [export / import / manage attachments / change storage] + theme/help controls + - save-status dot), `FolderRail` (collapsible nested-folder tree left of the list — select/scope, +- `src/components/` — `FolderGate` (first-run storage choice + folder re-permission gate + a recents + list so a failed probe is never a dead end; **desktop is folder-first** — in-browser storage is + offered on the web only), `Workspace` (top bar + layout + nav wiring; takes the + `NoteStore` and a `workspaceId` — App remounts it `key`ed per workspace, so switches start clean; + owns export/import and the flush-then-confirm guard before any workspace switch; per-workspace UI + layout persists under `gravity-notes::*` localStorage keys, adopting the legacy un-namespaced + value once), `TopBar` (nvALT search box + orb menu [export / import / manage attachments / trash / + **Open Recent submenu** (click = switch in place, ⌘-click = new window on desktop; "Workspaces…" + opens the ⌃R switcher) / **Open Folder…**] + theme/help controls + save-status dot), + `WorkspaceSwitcherDialog` (the ⌃R recents switcher; shares the keyboard model with MoveToDialog via + `useListboxNav`: filter + ↑/↓ + ↵ open / ⌘↵ new window / ⌘⌫ remove; OTHER workspaces lead by recency + with the current one parked last, and the top row is pre-highlighted, so ⌃R↵ ⌃R↵ ping-pongs between + the two most recent workspaces; a pinned "Open Folder…" action row sits outside the filter; the + in-browser row is offered on the web only — an existing in-app registry entry still lists so no data + strands), `FolderRail` (collapsible + nested-folder tree left of the list — select/scope, drag-and-drop, rename, pin; toggle ⌘⇧\), `NoteList` (sidebar with create/rename/delete/move, pin, sort; **virtualized** via `@tanstack/react-virtual`, with a `rangeExtractor` that keeps the keyboard-focused row and any open row popover's anchor row (⋯ menu / icon picker) mounted; rows render only an `IconPickerButton` glyph and share ONE `IconPickerPopup` — like the one shared row menu — so a closed picker costs nothing per row and scrolling can't unmount an open one), `MoveToDialog` (the ⌘⇧M move-to-folder picker — the chord is - list-scoped via `inTyping:false`, so in the editor ⌘⇧M stays the markdown heading shortcut), + list-scoped via `inTyping:false`, so in the editor ⌘⇧M stays the markdown heading shortcut; shares + `useListboxNav` with the workspace switcher), `EditorPane` (wraps the Gravity markdown editor; re-created per editing session via a stable `useNotes.sessionId`, so a rename doesn't remount it; saves/restores per-note **scroll + caret** on switch — the reused editor would otherwise carry the previous note's scrollTop; the floating diff --git a/README.md b/README.md index 1f8136f..258f9b0 100644 --- a/README.md +++ b/README.md @@ -126,19 +126,20 @@ Access API), `TauriNoteStore` (`src/storage/tauriStore.ts`, the same `.md` folde commands in the desktop app), and `IndexedDbNoteStore` (`src/storage/indexedDbStore.ts`, in-browser). All share the same `.md` ids, canonical body shape, and `updatedAt`-based conflict semantics (`src/storage/noteText.ts`), so everything above the seam is backend-agnostic. Per-folder/-store -metadata — sort mode, pins, created stamps, the open note — lives in `metadata.ts`. The chosen -backend (and any folder handle or path) is remembered in IndexedDB (`src/storage/handlePersistence.ts`). -The desktop shell is `src-tauri/` (Tauri 2); its only app code is the `notes_*` filesystem commands in -`src-tauri/src/lib.rs`. +metadata — sort mode, pins, created stamps, the open note — lives in `metadata.ts`. Every opened +folder/store is a **workspace** in an IndexedDB registry (`src/storage/workspaceRegistry.ts`) that +feeds the recents UI: the orb menu's "Open Recent" submenu, the ⌃R switcher, and (desktop) one native +window per workspace. The desktop shell is `src-tauri/` (Tauri 2); its app code is the `notes_*` +filesystem commands plus the workspace-window commands in `src-tauri/src/lib.rs`. Key modules: - `src/storage/` — `types.ts` (the `NoteStore` seam), `fileSystemStore.ts` + `tauriStore.ts` + `indexedDbStore.ts` (the three backends), `noteText.ts` (shared id/body helpers), `metadata.ts` - (sort/pins sidecar), `transfer.ts` (`.md` zip export/import), `handlePersistence.ts` (remembered - backend + handle/path) -- `src/hooks/useNotesStorage.ts` — first-run storage choice + permission lifecycle; yields a ready - `NoteStore` + (sort/pins sidecar), `transfer.ts` (`.md` zip export/import), `workspaceRegistry.ts` (the + workspace registry: known folders/stores + recency + last-active) +- `src/hooks/useNotesStorage.ts` — workspace lifecycle (first-run choice, restore, switching, + new-window opens) + FSA permission lifecycle; yields a ready `NoteStore` - `src/hooks/useNotes.ts` — note list, selection, debounced autosave, and conflict detection - `src/hooks/useNoteNavigation.ts`, `useNoteSearch.ts`, `useBacklinks.ts`, `useShortcuts.ts` — cursor/focus flow, full-text search-or-create and backlinks (both scoring a shared in-memory corpus @@ -172,8 +173,7 @@ Key modules: - Search index update on external file updates (+ reload workspace menu item?) - Notion-like font, width and density setting for each note? And ability to set default for all notes -- Multi-window native app (with different workspaces) -- Recent workspaces menu item (with cmd+r shortcut) +- Restore all workspace windows on relaunch (today only the last-active one comes back) - Easter egg in top bar (to the right of the search bar) ? diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1fbad59..d2f982f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,10 +2,13 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": ["main"], + "windows": ["main", "ws-*"], "permissions": [ "core:default", "core:window:allow-start-dragging", + "core:window:allow-destroy", + "core:window:allow-set-title", + "core:window:allow-set-theme", "dialog:default", "updater:default", "process:default" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6495c02..e258377 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,17 +15,19 @@ //! fs-plugin's scope allowlist. Times are returned as epoch-millisecond `f64`s to match //! the web backend's `file.lastModified`. +use std::collections::{HashMap, HashSet}; use std::fs; use std::io::Read; use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; use std::time::UNIX_EPOCH; use serde::Serialize; -// `Emitter` brings `app.emit` into scope for the menu-event handler (cross-platform). +// `Emitter` brings `app.emit_to` into scope for the menu-event handler (cross-platform). use tauri::Emitter; -// Only the macOS window/Dock handlers below call `Manager::get_webview_window`; elsewhere `App`'s -// inherent `handle()` is used, so on non-macOS builds the trait import would be dead (-D warnings). -#[cfg(target_os = "macos")] +// `Manager` brings `get_webview_window`/`webview_windows`/`state` into scope for the window +// handlers and the workspace-window commands. use tauri::Manager; /// Note files end in `.md` (matched case-insensitively, like the web backend). @@ -692,6 +694,186 @@ fn open_external(url: String) -> Result<(), String> { } } +/// Per-window workspace tracking. +/// - `labels`: a live window's label → the workspace id it shows. Fed by the frontend +/// (`set_window_workspace` on every workspace open) and by `open_workspace_window` (which assigns +/// the workspace *before* the window's page loads); drained by the `Destroyed` window event. +/// - `pending`: workspace ids whose window is currently being built. During the build gap the +/// window isn't yet resolvable via `get_webview_window`, so without this marker a second +/// `open_workspace_window` for the same workspace would treat the label as dead, prune it, and +/// spawn a duplicate window. +/// +/// Powers focus-if-open, so the same workspace never casually ends up in two windows. +#[derive(Default)] +struct WindowState { + labels: HashMap<String, String>, + pending: HashSet<String>, +} + +#[derive(Default)] +struct WindowWorkspaces(Mutex<WindowState>); + +/// Monotonic suffix for `ws-N` window labels. Uniqueness only matters within one app run — +/// nothing restores workspace windows across launches. +static NEXT_WS_WINDOW: AtomicU32 = AtomicU32::new(1); + +/// The label of a window showing `ws_id`, excluding `exclude` (a window never "finds itself" +/// when asking where else its target workspace is open). +fn window_label_for_workspace( + map: &HashMap<String, String>, + ws_id: &str, + exclude: Option<&str>, +) -> Option<String> { + map.iter() + .find(|(label, ws)| ws.as_str() == ws_id && Some(label.as_str()) != exclude) + .map(|(label, _)| label.clone()) +} + +/// This window's assigned workspace id, if any. A window created by `open_workspace_window` is +/// assigned before its page loads; the main window has no assignment at launch (the frontend +/// falls back to the last-active workspace). +#[tauri::command] +fn window_workspace( + window: tauri::WebviewWindow, + state: tauri::State<'_, WindowWorkspaces>, +) -> Option<String> { + state.0.lock().unwrap().labels.get(window.label()).cloned() +} + +/// Record which workspace this window is showing (the frontend calls this on every workspace +/// open, including in-place switches), keeping focus-if-open accurate. +#[tauri::command] +fn set_window_workspace( + window: tauri::WebviewWindow, + state: tauri::State<'_, WindowWorkspaces>, + ws_id: String, +) { + state + .0 + .lock() + .unwrap() + .labels + .insert(window.label().to_string(), ws_id); +} + +/// Focus another window already showing `ws_id` — never the calling one. Returns whether a window +/// was focused (false tells the caller to switch in place). Map entries whose window is gone +/// (e.g. a crash skipped the `Destroyed` cleanup) are pruned along the way. +#[tauri::command] +fn focus_workspace_window( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + state: tauri::State<'_, WindowWorkspaces>, + ws_id: String, +) -> bool { + let mut st = state.0.lock().unwrap(); + // A window for this workspace is mid-creation elsewhere — treat it as "already opening" + // rather than switching in place (which would then race the appearing window into a duplicate). + if st.pending.contains(&ws_id) { + return true; + } + while let Some(label) = window_label_for_workspace(&st.labels, &ws_id, Some(window.label())) { + if let Some(target) = app.get_webview_window(&label) { + // Don't hold the lock across window ops — they may hop to the main thread. + drop(st); + let _ = target.show(); + let _ = target.set_focus(); + return true; + } + st.labels.remove(&label); + } + false +} + +/// Open a workspace in its own window: focus the window already showing it (including the +/// caller's), or create a fresh `ws-N` window assigned to it. Returns whether an existing window +/// was focused rather than a new one created. +/// +/// Async on purpose: sync commands run on the main thread, where `WebviewWindowBuilder::build` +/// is documented to deadlock on some platforms; from the async runtime it safely proxies window +/// creation to the event loop. +#[tauri::command] +async fn open_workspace_window( + app: tauri::AppHandle, + state: tauri::State<'_, WindowWorkspaces>, + ws_id: String, + title: String, +) -> Result<bool, String> { + let label = { + let mut st = state.0.lock().unwrap(); + // A window for this workspace is already being built (another open in flight) — don't + // spawn a second. The in-flight call will show its window when its build completes. + if st.pending.contains(&ws_id) { + return Ok(true); + } + // Focus an existing live window; prune only genuinely-dead labels. Because no build is in + // flight for this workspace (pending checked above), a label whose window is missing is + // truly gone, not still-building — so pruning here can't destroy a pending window. + while let Some(existing) = window_label_for_workspace(&st.labels, &ws_id, None) { + if let Some(target) = app.get_webview_window(&existing) { + drop(st); + let _ = target.show(); + let _ = target.set_focus(); + return Ok(true); + } + st.labels.remove(&existing); + } + let label = format!("ws-{}", NEXT_WS_WINDOW.fetch_add(1, Ordering::SeqCst)); + // Assign the workspace BEFORE the window exists (so the page's first `window_workspace` + // ask can't race it), and mark it pending so a concurrent open during the build gap — + // when `get_webview_window` still returns None — sees `pending` and bails instead of + // pruning this label and creating a duplicate. + st.labels.insert(label.clone(), ws_id.clone()); + st.pending.insert(ws_id.clone()); + label + }; + // Clone the main window's config so the new window inherits every option (Overlay title bar, + // sizes, dragDropEnabled — and the dev config's differences) without hand-mirroring them. + let mut config = app.config().app.windows[0].clone(); + config.label = label.clone(); + config.title = title; + let built = tauri::WebviewWindowBuilder::from_config(&app, &config) + .and_then(|builder| builder.build()); + // Clear the pending marker regardless of outcome; on a build failure the page never loaded, + // so drop the pre-assigned label too (nothing else will ever release it). + { + let mut st = state.0.lock().unwrap(); + st.pending.remove(&ws_id); + if built.is_err() { + st.labels.remove(&label); + } + } + let window = built.map_err(stringify)?; + apply_macos_chrome(&window); + Ok(false) +} + +/// macOS window chrome shared by the main window (at setup) and each workspace window: center the +/// traffic lights in the taller custom title bar (titleBarStyle "Overlay"), and paint the webview +/// backdrop in the resolved theme so a fresh window doesn't flash white before the page background +/// loads (the index.html anti-flash style covers the content paint; this covers the empty frame +/// before it). +fn apply_macos_chrome(window: &tauri::WebviewWindow) { + #[cfg(target_os = "macos")] + { + use tauri_plugin_decorum::WebviewWindowExt; + // Nudge the traffic lights down + right to center them in our bar; decorum re-applies the + // inset on resize/fullscreen. (x = right, y = down.) + let _ = window.set_traffic_lights_inset(16.0, 20.0); + // Colors mirror Gravity's base background (dark tuned in index.css). Default to dark if + // the theme can't be read — "better dark than white" (the requested fallback). + let dark = window.theme().map(|t| t == tauri::Theme::Dark).unwrap_or(true); + let bg = if dark { + tauri::window::Color(33, 30, 26, 255) + } else { + tauri::window::Color(255, 255, 255, 255) + }; + let _ = window.set_background_color(Some(bg)); + } + #[cfg(not(target_os = "macos"))] + let _ = window; +} + /// Build the application menu. On macOS the app submenu's "About <App>" is a *custom* item (id /// `about`) that emits `menu:about` to the frontend (opening our own about dialog with clickable /// links); the rest mirrors Tauri's default menu so Edit (copy/paste/undo), View and Window keep @@ -773,8 +955,26 @@ pub fn run() { .menu(build_menu) .on_menu_event(|app, event| { if event.id() == "about" { - // The frontend (Workspace) listens for this and opens <AboutDialog>. - let _ = app.emit("menu:about", ()); + // The frontend (Workspace) listens for this and opens <AboutDialog>. Target one + // window — a broadcast would pop the dialog in every open workspace window at once. + // Prefer the focused window; if none is focused (the app is backgrounded, or main + // was hidden with ⌘W), fall back to any VISIBLE window before re-showing the hidden + // main — otherwise picking About while a ws-N window is visible-but-unfocused would + // yank the hidden main forward and open About on the wrong window. + let windows = app.webview_windows(); + let target = windows + .values() + .find(|window| window.is_focused().unwrap_or(false)) + .or_else(|| windows.values().find(|w| w.is_visible().unwrap_or(false))); + if let Some(window) = target { + let _ = window.set_focus(); + let _ = app.emit_to(window.label(), "menu:about", ()); + } else if let Some(main) = app.get_webview_window("main") { + // Nothing visible at all — re-show main and tell it. + let _ = main.show(); + let _ = main.set_focus(); + let _ = app.emit_to("main", "menu:about", ()); + } } }) .plugin(tauri_plugin_dialog::init()) @@ -783,6 +983,7 @@ pub fn run() { // signed `.app.tar.gz` against the pubkey in tauri.conf.json; `process` provides relaunch(). .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) + .manage(WindowWorkspaces::default()) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -791,41 +992,29 @@ pub fn run() { .build(), )?; } - // The custom title bar (titleBarStyle "Overlay") is taller than the standard macOS one, - // so the traffic lights sit too high/left by default. Nudge them down + right to center - // them in our bar; decorum re-applies the inset on resize/fullscreen. (x = right, y = down.) - #[cfg(target_os = "macos")] - { - use tauri_plugin_decorum::WebviewWindowExt; - if let Some(window) = app.get_webview_window("main") { - let _ = window.set_traffic_lights_inset(16.0, 20.0); - // Paint the webview backdrop in the resolved theme so launch doesn't flash white - // before the page background loads. The index.html anti-flash style covers the - // content paint; this covers the empty frame before it. Colors mirror Gravity's - // base background (dark tuned in index.css). Default to dark if the theme can't be - // read — "better dark than white" (the requested fallback). - let dark = window.theme().map(|t| t == tauri::Theme::Dark).unwrap_or(true); - let bg = if dark { - tauri::window::Color(33, 30, 26, 255) - } else { - tauri::window::Color(255, 255, 255, 255) - }; - let _ = window.set_background_color(Some(bg)); - } + if let Some(window) = app.get_webview_window("main") { + apply_macos_chrome(&window); } Ok(()) }) .on_window_event(|window, event| { - // macOS convention: the red close button / ⌘W hides the window and leaves the app - // running in the Dock + menu bar, rather than quitting. ⌘Q still quits (ExitRequested). - #[cfg(target_os = "macos")] - if let tauri::WindowEvent::CloseRequested {api, ..} = event { - api.prevent_close(); - let _ = window.hide(); + match event { + // macOS convention: the red close button / ⌘W on the MAIN window hides it and + // leaves the app running in the Dock + menu bar, rather than quitting. Workspace + // (`ws-N`) windows really close — the frontend flushes pending edits in its + // close-requested listener, then lets the close proceed. ⌘Q still quits. + #[cfg(target_os = "macos")] + tauri::WindowEvent::CloseRequested {api, ..} if window.label() == "main" => { + api.prevent_close(); + let _ = window.hide(); + } + // A closed window's workspace assignment must not keep answering focus-if-open. + tauri::WindowEvent::Destroyed => { + let state = window.state::<WindowWorkspaces>(); + state.0.lock().unwrap().labels.remove(window.label()); + } + _ => {} } - // The handler is macOS-only; consume the args elsewhere so the build stays warning-free. - #[cfg(not(target_os = "macos"))] - let _ = (window, event); }) .invoke_handler(tauri::generate_handler![ notes_list, @@ -848,16 +1037,26 @@ pub fn run() { notes_remove_dir_all, notes_move_dir, notes_list_folders, + window_workspace, + set_window_workspace, + focus_workspace_window, + open_workspace_window, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app_handle, event| { - // macOS: clicking the Dock icon (Reopen) re-shows the hidden window. + // macOS: clicking the Dock icon (Reopen) re-shows the hidden main window — but only + // when nothing is visible, so it doesn't yank main above an open workspace window. #[cfg(target_os = "macos")] - if let tauri::RunEvent::Reopen {..} = event { - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.show(); - let _ = window.set_focus(); + if let tauri::RunEvent::Reopen { + has_visible_windows, .. + } = event + { + if !has_visible_windows { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } } } // The handler is macOS-only; consume the args elsewhere so the build stays warning-free. @@ -1269,4 +1468,29 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + + #[test] + fn window_map_lookup_excludes_the_caller_and_respects_removal() { + let mut map = HashMap::new(); + map.insert("main".to_string(), "tauri:/a".to_string()); + map.insert("ws-1".to_string(), "tauri:/b".to_string()); + + // Found by workspace id… + assert_eq!( + window_label_for_workspace(&map, "tauri:/b", None), + Some("ws-1".to_string()) + ); + // …but never the asking window itself (a switch must not "focus" its own window). + assert_eq!(window_label_for_workspace(&map, "tauri:/b", Some("ws-1")), None); + assert_eq!( + window_label_for_workspace(&map, "tauri:/a", Some("ws-1")), + Some("main".to_string()) + ); + // Unknown workspace → none. + assert_eq!(window_label_for_workspace(&map, "tauri:/c", None), None); + + // A destroyed window's entry stops answering once removed (the Destroyed handler's job). + map.remove("ws-1"); + assert_eq!(window_label_for_workspace(&map, "tauri:/b", None), None); + } } diff --git a/src/App.tsx b/src/App.tsx index 0f96732..ba01513 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,11 +65,22 @@ export function App() { <ErrorBoundary> {storage.state === 'ready' && storage.store ? ( <Workspace + // Keyed by workspace: switching remounts the whole tree, so every + // per-workspace piece (list cursor, rail state, editor session, + // corpus) starts clean instead of reconciling across workspaces. + key={storage.activeWorkspaceId ?? 'workspace'} store={storage.store} + workspaceId={storage.activeWorkspaceId ?? 'workspace'} storageLabel={storage.storageLabel} + workspaces={storage.workspaces} themePref={themePref} onChangeThemePref={setThemePref} - onChangeStorage={() => void storage.reset()} + onOpenWorkspace={storage.openWorkspace} + onOpenWorkspaceInNewWindow={storage.openInNewWindow} + onRemoveWorkspace={storage.removeWorkspace} + onRefreshWorkspaces={storage.refreshWorkspaces} + onOpenFolder={() => void storage.pickFolder()} + supportsFolders={storage.supportsFolders} /> ) : ( <FolderGate storage={storage} /> diff --git a/src/components/FolderGate.css b/src/components/FolderGate.css index 812b98b..58ce912 100644 --- a/src/components/FolderGate.css +++ b/src/components/FolderGate.css @@ -31,3 +31,14 @@ .folder-gate__error { margin-top: 4px; } + +/* Recent workspaces under the storage choice: a quiet caption + full-width flat buttons. */ +.folder-gate__recents { + display: flex; + flex-direction: column; + gap: 4px; + align-self: stretch; + margin-top: 8px; + padding-top: 12px; + border-top: 1px solid var(--g-color-line-generic); +} diff --git a/src/components/FolderGate.test.tsx b/src/components/FolderGate.test.tsx index d81e714..8cbdfe7 100644 --- a/src/components/FolderGate.test.tsx +++ b/src/components/FolderGate.test.tsx @@ -15,6 +15,8 @@ function makeStorage(over: Partial<NotesStorage> = {}): NotesStorage { store: null, backend: null, storageLabel: null, + activeWorkspaceId: null, + workspaces: [], error: null, isTauri, supportsFileSystem, @@ -24,6 +26,10 @@ function makeStorage(over: Partial<NotesStorage> = {}): NotesStorage { useBrowserStorage: vi.fn(async () => {}), grantPermission: vi.fn(async () => {}), reset: vi.fn(async () => {}), + openWorkspace: vi.fn(async () => true), + openInNewWindow: vi.fn(async () => {}), + removeWorkspace: vi.fn(async () => {}), + refreshWorkspaces: vi.fn(async () => {}), ...over, }; } @@ -52,16 +58,18 @@ describe('FolderGate', () => { expect(storage.useBrowserStorage).toHaveBeenCalledTimes(1); }); - it('offers a native folder option inside the desktop app (no Chromium caption)', async () => { + it('offers ONLY the native folder option inside the desktop app (folder-first)', async () => { const user = userEvent.setup(); - // In the Tauri shell the FS API is absent, but native folders are available. + // In the Tauri shell the FS API is absent, but native folders are available — and they + // are the only choice: the desktop app doesn't offer in-app (IndexedDB) storage. const storage = makeStorage({isTauri: true, supportsFileSystem: false}); renderWithProviders(<FolderGate storage={storage} />); expect(screen.getByRole('button', {name: /Open a folder/})).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: /Store in/})).not.toBeInTheDocument(); expect(screen.queryByText(/needs a Chromium browser/)).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', {name: 'Store inside the app'})); - expect(storage.useBrowserStorage).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', {name: /Open a folder/})); + expect(storage.pickFolder).toHaveBeenCalledTimes(1); }); it('shows the re-grant prompt with the folder name and wires its actions', async () => { @@ -86,4 +94,31 @@ describe('FolderGate', () => { ); expect(screen.getByText('Could not open the folder.')).toBeInTheDocument(); }); + + it('lists recent workspaces on the choice screen and opens one on click', async () => { + const user = userEvent.setup(); + const storage = makeStorage({ + workspaces: [ + {id: 'tauri:/Users/me/Vault', backend: 'tauri-fs', name: 'Vault'}, + {id: 'indexeddb', backend: 'indexeddb', name: 'In this browser'}, + ], + }); + renderWithProviders(<FolderGate storage={storage} />); + + expect(screen.getByText(/reopen a recent workspace/)).toBeInTheDocument(); + await user.click(screen.getByRole('button', {name: /Vault/})); + expect(storage.openWorkspace).toHaveBeenCalledWith('tauri:/Users/me/Vault'); + }); + + it('hides the recents list while loading (a click would race the restore)', () => { + renderWithProviders( + <FolderGate + storage={makeStorage({ + state: 'loading', + workspaces: [{id: 'tauri:/A', backend: 'tauri-fs', name: 'A'}], + })} + />, + ); + expect(screen.queryByText(/reopen a recent workspace/)).not.toBeInTheDocument(); + }); }); diff --git a/src/components/FolderGate.tsx b/src/components/FolderGate.tsx index ed4d510..244432b 100644 --- a/src/components/FolderGate.tsx +++ b/src/components/FolderGate.tsx @@ -1,10 +1,13 @@ -import {Folder} from '@gravity-ui/icons'; +import {Database, Folder} from '@gravity-ui/icons'; import {Button, Card, Icon, Text} from '@gravity-ui/uikit'; import type {NotesStorage} from '../hooks/useNotesStorage'; import './FolderGate.css'; +/** How many recents the choice screen offers (the full list lives in the in-app switcher). */ +const MAX_GATE_RECENTS = 5; + /** * Full-screen gate shown until a storage backend is chosen and ready. Renders the first-run * "where do your notes live" choice (a folder on your computer, or in this browser) and the @@ -52,9 +55,11 @@ function Content({storage}: {storage: NotesStorage}) { <Icon data={Folder} size={32} className="folder-gate__icon" /> <Text variant="header-1">Welcome to Gravity Notes</Text> <Text color="secondary"> - Choose where to keep your notes. Each note is a plain Markdown file you fully own — - stored in a folder on your computer, or{' '} - {storage.isTauri ? 'inside the app' : 'inside this browser'}. + {storage.isTauri + ? // The desktop app is folder-first: notes are real .md files on disk, always. + 'Choose your notes folder. Each note is a plain Markdown file you fully own.' + : 'Choose where to keep your notes. Each note is a plain Markdown file you ' + + 'fully own — stored in a folder on your computer, or inside this browser.'} </Text> <div className="folder-gate__actions"> {storage.supportsFolders ? ( @@ -68,18 +73,23 @@ function Content({storage}: {storage: NotesStorage}) { Open a folder… </Button> ) : null} - <Button - view={storage.supportsFolders ? 'outlined' : 'action'} - size="l" - loading={storage.state === 'loading' && !storage.supportsFolders} - // Disable while the remembered choice is still being restored, so a click can't - // discard it mid-bootstrap (the folder button already showed a spinner; this one - // didn't when `supportsFolders`, leaving it racily clickable). - disabled={storage.state === 'loading'} - onClick={() => void storage.useBrowserStorage()} - > - {storage.isTauri ? 'Store inside the app' : 'Store in this browser'} - </Button> + {/* In-browser storage is a WEB option (a fallback where folder access is patchy); + the desktop app always uses a real folder. */} + {storage.isTauri ? null : ( + <Button + view={storage.supportsFolders ? 'outlined' : 'action'} + size="l" + loading={storage.state === 'loading' && !storage.supportsFolders} + // Disable while the remembered choice is still being restored, so a click + // can't discard it mid-bootstrap (the folder button already showed a + // spinner; this one didn't when `supportsFolders`, leaving it racily + // clickable). + disabled={storage.state === 'loading'} + onClick={() => void storage.useBrowserStorage()} + > + Store in this browser + </Button> + )} </div> {!storage.supportsFolders ? ( <Text variant="caption-2" color="secondary"> @@ -87,6 +97,28 @@ function Content({storage}: {storage: NotesStorage}) { notes to a folder later by exporting them. </Text> ) : null} + {/* Known workspaces, so landing here (first run aside — e.g. after a failed folder + probe) is never a dead end: any recent is one click away. Only once bootstrap has + settled — clicking mid-'loading' would race the restore. */} + {storage.state === 'choosing' && storage.workspaces.length > 0 ? ( + <div className="folder-gate__recents"> + <Text variant="caption-2" color="secondary"> + Or reopen a recent workspace: + </Text> + {storage.workspaces.slice(0, MAX_GATE_RECENTS).map((ws) => ( + <Button + key={ws.id} + view="flat" + size="m" + width="max" + onClick={() => void storage.openWorkspace(ws.id)} + > + <Icon data={ws.backend === 'indexeddb' ? Database : Folder} size={14} /> + {ws.name} + </Button> + ))} + </div> + ) : null} </> ); } diff --git a/src/components/MoveToDialog.tsx b/src/components/MoveToDialog.tsx index 7c5c35c..c1c3c0e 100644 --- a/src/components/MoveToDialog.tsx +++ b/src/components/MoveToDialog.tsx @@ -1,14 +1,16 @@ import {useEffect, useMemo, useRef, useState} from 'react'; -import type {KeyboardEvent as ReactKeyboardEvent, ReactNode} from 'react'; import {ChevronDown, ChevronRight, Folder, House} from '@gravity-ui/icons'; import {Dialog, Icon, Text, TextInput} from '@gravity-ui/uikit'; import {useHeldValue} from '../hooks/useHeldValue'; +import {useListboxNav} from '../hooks/useListboxNav'; import {dirname} from '../storage/noteText'; import type {NoteMeta, NotesMetadata} from '../storage/types'; import {type MoveTargetRow, buildMoveTargets} from '../tree'; +import {highlightMatch} from './highlightMatch'; + import './MoveToDialog.css'; export interface MoveToDialogProps { @@ -39,20 +41,6 @@ function indentFor(depth: number): number { return 12 + depth * 16; } -/** Wrap the first case-insensitive occurrence of `q` in `name` with a highlight `<mark>`. */ -function highlight(name: string, q: string): ReactNode { - if (!q) return name; - const idx = name.toLowerCase().indexOf(q.toLowerCase()); - if (idx === -1) return name; - return ( - <> - {name.slice(0, idx)} - <mark className="move-to__match">{name.slice(idx, idx + q.length)}</mark> - {name.slice(idx + q.length)} - </> - ); -} - export function MoveToDialog({ open, note, @@ -70,9 +58,7 @@ export function MoveToDialog({ const [query, setQuery] = useState(''); // The picker keeps its own collapse state — opening it must not touch the rail's. const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set()); - const [activeIndex, setActiveIndex] = useState(0); const inputRef = useRef<HTMLInputElement>(null); - const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const q = query.trim(); const ql = q.toLowerCase(); @@ -101,6 +87,23 @@ export function MoveToDialog({ return rootShown ? [root, ...tail] : tail; }, [folderRows, currentFolder, ql]); + const commit = (entry: Entry | undefined) => { + if (!entry || entry.disabled) return; + onMove(entry.path); + }; + + // Shared filter-list keyboard model (↑/↓ skip-disabled, ↵ commit, Esc close) on a document + // listener — see useListboxNav for why it can't be input-scoped (Gravity's Dialog can park + // focus off the input, silencing an onKeyDown handler). + const {activeIndex, setActiveIndex, registerRow} = useListboxNav<Entry>({ + open, + items: entries, + getKey: (entry) => entry.key, + isDisabled: (entry) => entry.disabled, + onEnter: (index) => commit(entries[index]), + onClose, + }); + // Reset everything when the dialog opens (or the target note changes). useEffect(() => { if (open) { @@ -121,14 +124,7 @@ export function MoveToDialog({ const firstMatched = entries.findIndex((e) => e.matched && !e.disabled); const firstSelectable = entries.findIndex((e) => !e.disabled); setActiveIndex(firstMatched >= 0 ? firstMatched : firstSelectable); - }, [entries, open, ql]); - - // Keep the active row in view as it moves. - useEffect(() => { - if (!open) return; - const active = entries[activeIndex]; - if (active) rowRefs.current.get(active.key)?.scrollIntoView?.({block: 'nearest'}); - }, [activeIndex, entries, open]); + }, [entries, open, ql, setActiveIndex]); // Focus the filter field on open, so you can type-to-narrow immediately. useEffect(() => { @@ -144,49 +140,6 @@ export function MoveToDialog({ }); }; - // Step the highlight to the next selectable row in `delta` direction (clamped, skips disabled). - // From "nothing highlighted" (-1), ArrowDown lands on the first selectable row, ArrowUp the last. - const firstSelectable = () => entries.findIndex((e) => !e.disabled); - const lastSelectable = () => { - for (let i = entries.length - 1; i >= 0; i--) if (!entries[i].disabled) return i; - return -1; - }; - const moveActive = (delta: number) => { - setActiveIndex((cur) => { - if (cur < 0) return delta > 0 ? firstSelectable() : lastSelectable(); - for (let i = cur + delta; i >= 0 && i < entries.length; i += delta) { - if (!entries[i].disabled) return i; - } - return cur; - }); - }; - - const commit = (entry: Entry | undefined) => { - if (!entry || entry.disabled) return; - onMove(entry.path); - }; - - const onInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => { - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - moveActive(1); - break; - case 'ArrowUp': - event.preventDefault(); - moveActive(-1); - break; - case 'Enter': - event.preventDefault(); - commit(entries[activeIndex]); - break; - case 'Escape': - event.preventDefault(); - onClose(); - break; - } - }; - const activeKey = entries[activeIndex]?.key; const renderEntry = (entry: Entry, index: number) => { @@ -198,10 +151,7 @@ export function MoveToDialog({ <div key={entry.key} id={`move-to-opt-${entry.key}`} - ref={(el) => { - if (el) rowRefs.current.set(entry.key, el); - else rowRefs.current.delete(entry.key); - }} + ref={(el) => registerRow(entry.key, el)} className={ 'move-to__row' + (active ? ' move-to__row_active' : '') + @@ -239,7 +189,7 @@ export function MoveToDialog({ aria-hidden /> <Text className="move-to__name" ellipsis> - {highlight(entry.name, ql)} + {highlightMatch(entry.name, ql, 'move-to__match')} </Text> {entry.disabled ? <span className="move-to__hint">current</span> : null} </div> @@ -247,7 +197,16 @@ export function MoveToDialog({ }; return ( - <Dialog open={open} onClose={onClose} size="s" disableBodyScrollLock> + // initialFocus hands the filter input to the Dialog's focus manager; otherwise it focuses + // the dialog container, where the document-level key handler (useListboxNav) still works + // but type-to-filter wouldn't land in the input. + <Dialog + open={open} + onClose={onClose} + size="s" + disableBodyScrollLock + initialFocus={inputRef} + > <Dialog.Header caption={noteView ? `Move “${noteView.title}” to…` : 'Move'} /> <Dialog.Body> <TextInput @@ -256,7 +215,6 @@ export function MoveToDialog({ placeholder="Filter folders…" value={query} onUpdate={setQuery} - onKeyDown={onInputKeyDown} controlProps={{ role: 'combobox', 'aria-expanded': true, diff --git a/src/components/TopBar.test.tsx b/src/components/TopBar.test.tsx index 5bd597d..c22d213 100644 --- a/src/components/TopBar.test.tsx +++ b/src/components/TopBar.test.tsx @@ -4,6 +4,7 @@ import {fireEvent, screen} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {describe, expect, it, vi} from 'vitest'; +import type {WorkspaceInfo} from '../hooks/useNotesStorage'; import type {NoteMeta} from '../storage/types'; import {renderWithProviders} from '../test/render'; @@ -14,12 +15,25 @@ const NOTES: NoteMeta[] = [ {id: 'Beta.md', title: 'Beta', updatedAt: 2}, ]; +const WORKSPACES: WorkspaceInfo[] = [ + {id: 'tauri:/Users/me/notes', backend: 'tauri-fs', name: 'notes', path: '/Users/me/notes'}, + {id: 'tauri:/Users/me/work', backend: 'tauri-fs', name: 'work', path: '/Users/me/work'}, +]; + const SEARCH = 'Search or create a note…'; function setup(overrides: Record<string, unknown> = {}) { const props = { storageLabel: 'notes', - onChangeStorage: vi.fn(), + workspaces: WORKSPACES, + activeWorkspaceId: 'tauri:/Users/me/notes', + isDesktop: true, + supportsFolders: true, + onOpenWorkspace: vi.fn(), + onOpenWorkspaceInNewWindow: vi.fn(), + onOpenFolder: vi.fn(), + onOpenSwitcher: vi.fn(), + onMenuOpen: vi.fn(), onExport: vi.fn(), onImport: vi.fn(), onManageAttachments: vi.fn(), @@ -166,7 +180,14 @@ function StatefulTopBar({onCommit}: {onCommit: () => void}) { return ( <TopBar storageLabel="notes" - onChangeStorage={noop} + workspaces={[]} + activeWorkspaceId={null} + isDesktop={false} + supportsFolders + onOpenWorkspace={noop} + onOpenWorkspaceInNewWindow={noop} + onOpenFolder={noop} + onOpenSwitcher={noop} onExport={noop} onImport={noop} onManageAttachments={noop} @@ -243,10 +264,11 @@ describe('TopBar — inline autocomplete', () => { }); describe('TopBar — orb menu', () => { - it('exposes export / import / change-storage in the orb menu', async () => { + it('exposes export / import / open-folder in the orb menu (and reports the open)', async () => { const user = userEvent.setup(); const {props} = setup({storageLabel: 'my-notes'}); await user.click(screen.getByRole('button', {name: 'Menu'})); + expect(props.onMenuOpen).toHaveBeenCalledTimes(1); await user.click(await screen.findByRole('menuitem', {name: /Export all notes/})); expect(props.onExport).toHaveBeenCalledTimes(1); @@ -255,8 +277,61 @@ describe('TopBar — orb menu', () => { expect(props.onImport).toHaveBeenCalledTimes(1); await user.click(screen.getByRole('button', {name: 'Menu'})); - await user.click(await screen.findByRole('menuitem', {name: /Change storage/})); - expect(props.onChangeStorage).toHaveBeenCalledTimes(1); + await user.click(await screen.findByRole('menuitem', {name: /Open Folder/})); + expect(props.onOpenFolder).toHaveBeenCalledTimes(1); + }); + + it('hides "Open Folder…" when folder storage is unavailable', async () => { + const user = userEvent.setup(); + setup({supportsFolders: false}); + await user.click(screen.getByRole('button', {name: 'Menu'})); + await screen.findByRole('menuitem', {name: /Export all notes/}); + expect(screen.queryByRole('menuitem', {name: /Open Folder/})).not.toBeInTheDocument(); + }); + + it('switches to a recent workspace from the Open Recent submenu', async () => { + const user = userEvent.setup(); + const {props} = setup(); + await user.click(screen.getByRole('button', {name: 'Menu'})); + // Hover opens the submenu (deterministic in jsdom; click would toggle it). + fireEvent.mouseEnter(await screen.findByRole('menuitem', {name: /Open Recent/})); + await user.click(await screen.findByRole('menuitem', {name: 'work'})); + expect(props.onOpenWorkspace).toHaveBeenCalledWith('tauri:/Users/me/work'); + expect(props.onOpenWorkspaceInNewWindow).not.toHaveBeenCalled(); + }); + + it('⌘-clicking a recent opens it in a new window on desktop', async () => { + const user = userEvent.setup(); + const {props} = setup(); + await user.click(screen.getByRole('button', {name: 'Menu'})); + fireEvent.mouseEnter(await screen.findByRole('menuitem', {name: /Open Recent/})); + fireEvent.click(await screen.findByRole('menuitem', {name: 'work'}), {metaKey: true}); + expect(props.onOpenWorkspaceInNewWindow).toHaveBeenCalledWith('tauri:/Users/me/work'); + expect(props.onOpenWorkspace).not.toHaveBeenCalled(); + }); + + it('clicking the current workspace in Open Recent is a no-op', async () => { + const user = userEvent.setup(); + const {props} = setup(); + await user.click(screen.getByRole('button', {name: 'Menu'})); + fireEvent.mouseEnter(await screen.findByRole('menuitem', {name: /Open Recent/})); + // Two menu items are named "notes": the disabled storage-label line and the current + // recent — the recent is the enabled one. + const items = await screen.findAllByRole('menuitem', {name: 'notes'}); + const recent = items.find((el) => el.getAttribute('aria-disabled') !== 'true'); + expect(recent).toBeDefined(); + await user.click(recent!); + expect(props.onOpenWorkspace).not.toHaveBeenCalled(); + expect(props.onOpenWorkspaceInNewWindow).not.toHaveBeenCalled(); + }); + + it('opens the workspace switcher from the Open Recent submenu', async () => { + const user = userEvent.setup(); + const {props} = setup(); + await user.click(screen.getByRole('button', {name: 'Menu'})); + fireEvent.mouseEnter(await screen.findByRole('menuitem', {name: /Open Recent/})); + await user.click(await screen.findByRole('menuitem', {name: /Workspaces…/})); + expect(props.onOpenSwitcher).toHaveBeenCalledTimes(1); }); it('toggles the sidebar from the orb menu', async () => { diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index fd06a78..d9209c1 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,5 +1,9 @@ import {useLayoutEffect, useState} from 'react'; -import type {KeyboardEvent as ReactKeyboardEvent, RefObject} from 'react'; +import type { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + RefObject, +} from 'react'; import { ArrowDownToLine, @@ -7,7 +11,10 @@ import { ArrowsRotateRight, CircleArrowUp, CircleQuestion, + ClockArrowRotateLeft, + Database, Folder, + FolderOpen, Gear, LayoutSideContent, Picture, @@ -16,17 +23,36 @@ import { import {DropdownMenu, Icon, TextInput} from '@gravity-ui/uikit'; import type {SaveState} from '../hooks/useNotes'; +import type {WorkspaceInfo} from '../hooks/useNotesStorage'; import type {NoteMeta} from '../storage/types'; import {THEME_OPTIONS, type ThemePref} from './theme'; import './TopBar.css'; +/** How many recents the "Open Recent" submenu shows; the ⌃R switcher lists them all. */ +const MAX_RECENT_MENU = 8; + export interface TopBarProps { /** Active storage label (folder name, or "In this browser"); shown in the menu. */ storageLabel: string | null; - /** Switch backend (returns to the choice screen). */ - onChangeStorage: () => void; + /** Known workspaces, most recently opened first — feeds the "Open Recent" submenu. */ + workspaces: WorkspaceInfo[]; + activeWorkspaceId: string | null; + /** Desktop shell: ⌘-click on a recent opens it in a new window. */ + isDesktop: boolean; + /** Whether folder-on-disk workspaces are available (shows "Open Folder…"). */ + supportsFolders: boolean; + /** Switch this window to a recent workspace. */ + onOpenWorkspace: (id: string) => void; + /** Desktop only: open a recent workspace in its own window (⌘-click). */ + onOpenWorkspaceInNewWindow: (id: string) => void; + /** Open the folder picker (adds/opens a workspace in this window). */ + onOpenFolder: () => void; + /** Open the ⌃R workspace switcher dialog. */ + onOpenSwitcher: () => void; + /** The orb menu just opened — a chance to refresh the recents it shows. */ + onMenuOpen?: () => void; /** Export all notes as a .md zip. */ onExport: () => void; /** Import .md files / a zip into the current store. */ @@ -95,7 +121,15 @@ const STATUS_TEXT: Record<SaveState, string> = { */ export function TopBar({ storageLabel, - onChangeStorage, + workspaces, + activeWorkspaceId, + isDesktop, + supportsFolders, + onOpenWorkspace, + onOpenWorkspaceInNewWindow, + onOpenFolder, + onOpenSwitcher, + onMenuOpen, onExport, onImport, onManageAttachments, @@ -259,10 +293,41 @@ export function TopBar({ action: onOpenTrash, }, { - text: 'Change storage…', - iconStart: <Icon data={Folder} />, - action: onChangeStorage, + text: 'Open Recent', + iconStart: <Icon data={ClockArrowRotateLeft} />, + items: [ + // Recents (most recent first, the current one check-marked). Plain click + // switches this window; ⌘-click (desktop) opens a new window. + workspaces.slice(0, MAX_RECENT_MENU).map((ws) => ({ + text: ws.name, + iconStart: <Icon data={ws.backend === 'indexeddb' ? Database : Folder} />, + selected: ws.id === activeWorkspaceId, + // The uikit action gets a React mouse event on click but a NATIVE + // KeyboardEvent on Enter — both carry metaKey, which is all we need. + action: (event: ReactMouseEvent<HTMLElement> | KeyboardEvent) => { + if (ws.id === activeWorkspaceId) return; + if (isDesktop && event.metaKey) onOpenWorkspaceInNewWindow(ws.id); + else onOpenWorkspace(ws.id); + }, + })), + [ + { + text: 'Workspaces…', + iconEnd: <span className="topbar__menu-kbd">⌃R</span>, + action: onOpenSwitcher, + }, + ], + ], }, + ...(supportsFolders + ? [ + { + text: 'Open Folder…', + iconStart: <Icon data={FolderOpen} />, + action: onOpenFolder, + }, + ] + : []), ], [ { @@ -314,6 +379,9 @@ export function TopBar({ <header className="topbar" data-tauri-drag-region> <DropdownMenu switcherWrapperClassName="topbar__menu-anchor" + onOpenToggle={(open) => { + if (open) onMenuOpen?.(); + }} renderSwitcher={(props) => ( <button {...props} diff --git a/src/components/Workspace.test.tsx b/src/components/Workspace.test.tsx index bbf972d..611a15d 100644 --- a/src/components/Workspace.test.tsx +++ b/src/components/Workspace.test.tsx @@ -30,20 +30,29 @@ beforeEach(() => { Object.defineProperty(document, 'visibilityState', {configurable: true, get: () => 'visible'}); }); +/** The non-store Workspace props (workspace identity + switching callbacks), all inert. */ +function workspaceProps() { + return { + workspaceId: 'test-ws', + storageLabel: 'notes', + workspaces: [], + themePref: 'light' as const, + onChangeThemePref: vi.fn(), + onOpenWorkspace: vi.fn(async () => true), + onOpenWorkspaceInNewWindow: vi.fn(async () => {}), + onRemoveWorkspace: vi.fn(async () => {}), + onRefreshWorkspaces: vi.fn(async () => {}), + onOpenFolder: vi.fn(), + supportsFolders: true, + }; +} + function renderWorkspace() { const dir = new FakeDirectoryHandle(); dir.seedFile('Alpha.md', 'a', 100); dir.seedFile('Beta.md', 'b', 200); const store = new FileSystemNoteStore(asDirectoryHandle(dir)); - renderWithProviders( - <Workspace - store={store} - storageLabel="notes" - themePref="light" - onChangeThemePref={vi.fn()} - onChangeStorage={vi.fn()} - />, - ); + renderWithProviders(<Workspace store={store} {...workspaceProps()} />); return {dir, store}; } @@ -55,10 +64,10 @@ async function toggleSidebarViaMenu(user: ReturnType<typeof userEvent.setup>) { describe('Workspace — nvALT navigation', () => { afterEach(() => { - // Some tests persist to localStorage (sidebar collapse, settings); clear the keys so no - // state leaks into the other tests (jsdom shares localStorage across a suite). - localStorage.removeItem('gravity-notes:sidebar-collapsed'); - localStorage.removeItem('gravity-notes:settings'); + // Some tests persist to localStorage (sidebar collapse, settings); the layout keys are now + // namespaced per workspace id, so clear everything rather than chase key shapes (jsdom + // shares localStorage across a suite). + localStorage.clear(); }); // Collapse the sidebar, then fire ⌘' to peek it. Resolves once the peek class is present. @@ -417,12 +426,13 @@ describe('Workspace — nvALT navigation', () => { await waitFor(() => expect(document.querySelector('.workspace__body_collapsed')).not.toBeNull(), ); - expect(localStorage.getItem('gravity-notes:sidebar-collapsed')).toBe('true'); + // Layout state persists under the workspace-namespaced key. + expect(localStorage.getItem('gravity-notes:test-ws:sidebar-collapsed')).toBe('true'); await toggleSidebarViaMenu(user); await waitFor(() => expect(document.querySelector('.workspace__body_collapsed')).toBeNull(), ); - expect(localStorage.getItem('gravity-notes:sidebar-collapsed')).toBe('false'); + expect(localStorage.getItem('gravity-notes:test-ws:sidebar-collapsed')).toBe('false'); }); it('toggles the sidebar with ⌘\\', async () => { @@ -435,11 +445,15 @@ describe('Workspace — nvALT navigation', () => { ); }); - it('restores the collapsed sidebar from localStorage', async () => { + it('restores the collapsed sidebar from the pre-workspace key and adopts it', async () => { + // An upgrade scenario: only the legacy (un-namespaced) key exists. The first workspace + // opened inherits it — and consumes it, so "back to default" can't fall through later. localStorage.setItem('gravity-notes:sidebar-collapsed', 'true'); renderWorkspace(); await screen.findByRole('option', {name: /Alpha/}); expect(document.querySelector('.workspace__body_collapsed')).not.toBeNull(); + expect(localStorage.getItem('gravity-notes:test-ws:sidebar-collapsed')).toBe('true'); + expect(localStorage.getItem('gravity-notes:sidebar-collapsed')).toBeNull(); }); it("⌘' peeks the collapsed sidebar", async () => { @@ -605,15 +619,7 @@ describe('Workspace — nvALT navigation', () => { dir.seedFile('Recipes.md', 'pancakes need buttermilk', 100); dir.seedFile('Travel.md', 'flights to tokyo', 200); const store = new FileSystemNoteStore(asDirectoryHandle(dir)); - renderWithProviders( - <Workspace - store={store} - storageLabel="notes" - themePref="light" - onChangeThemePref={vi.fn()} - onChangeStorage={vi.fn()} - />, - ); + renderWithProviders(<Workspace store={store} {...workspaceProps()} />); await screen.findByRole('option', {name: /Recipes/}); await user.type(screen.getByPlaceholderText(/Search/), 'buttermilk'); // "buttermilk" lives only in Recipes' body — it surfaces once the corpus loads... @@ -636,15 +642,7 @@ describe('Workspace — nvALT navigation', () => { const preamble = 'lorem ipsum dolor sit amet '.repeat(10); // ~270 chars, no "kubernetes" dir.seedFile('Journal.md', `${preamble} then we discussed kubernetes at length`, 100); const store = new FileSystemNoteStore(asDirectoryHandle(dir)); - renderWithProviders( - <Workspace - store={store} - storageLabel="notes" - themePref="light" - onChangeThemePref={vi.fn()} - onChangeStorage={vi.fn()} - />, - ); + renderWithProviders(<Workspace store={store} {...workspaceProps()} />); await screen.findByRole('option', {name: /Journal/}); await user.type(screen.getByPlaceholderText(/Search/), 'kubernetes'); await waitFor(() => @@ -683,15 +681,7 @@ describe('Workspace — move picker', () => { dir.seedFile('Beta.md', 'b', 200); dir.seedFile('Work/Existing.md', 'x', 50); // makes the "Work" folder exist const store = new FileSystemNoteStore(asDirectoryHandle(dir)); - renderWithProviders( - <Workspace - store={store} - storageLabel="notes" - themePref="light" - onChangeThemePref={vi.fn()} - onChangeStorage={vi.fn()} - />, - ); + renderWithProviders(<Workspace store={store} {...workspaceProps()} />); return {store}; } @@ -757,15 +747,7 @@ describe('Workspace — folder auto-preview', () => { dir.seedFile('Root.md', 'r', 100); dir.seedFile('Work/Inside.md', 'i', 50); const store = new FileSystemNoteStore(asDirectoryHandle(dir)); - renderWithProviders( - <Workspace - store={store} - storageLabel="notes" - themePref="light" - onChangeThemePref={vi.fn()} - onChangeStorage={vi.fn()} - />, - ); + renderWithProviders(<Workspace store={store} {...workspaceProps()} />); await screen.findByRole('option', {name: /Root/}); // Open the folder rail, then select the Work folder. fireEvent.keyDown(document, {key: '\\', code: 'Backslash', metaKey: true, shiftKey: true}); @@ -790,19 +772,21 @@ describe('Workspace — storage menu', () => { expect(await screen.findByText('Attachments')).toBeInTheDocument(); }); - it('confirms before changing storage while an unresolved conflict holds unsaved edits', async () => { + it('confirms before switching workspaces while an unresolved conflict holds unsaved edits', async () => { const user = userEvent.setup(); const dir = new FakeDirectoryHandle(); dir.seedFile('Note.md', 'disk v1', 100); const store = new FileSystemNoteStore(asDirectoryHandle(dir)); - const onChangeStorage = vi.fn(); + const props = workspaceProps(); + const onOpenWorkspace = props.onOpenWorkspace; renderWithProviders( <Workspace store={store} - storageLabel="notes" - themePref="light" - onChangeThemePref={vi.fn()} - onChangeStorage={onChangeStorage} + {...props} + workspaces={[ + {id: 'test-ws', backend: 'filesystem', name: 'notes'}, + {id: 'tauri:/other', backend: 'tauri-fs', name: 'other'}, + ]} />, ); // Open the note, then create an external conflict (bump its mtime past the baseline). @@ -813,18 +797,22 @@ describe('Workspace — storage menu', () => { // Wait for the conflict banner so we know an unresolved conflict is holding the note. await screen.findByText('Changed on disk'); - // Decline the confirm: storage must NOT change. + const switchToOther = async () => { + await user.click(screen.getByRole('button', {name: 'Menu'})); + fireEvent.mouseEnter(await screen.findByRole('menuitem', {name: /Open Recent/})); + await user.click(await screen.findByRole('menuitem', {name: 'other'})); + }; + + // Decline the confirm: the workspace must NOT switch. const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); - await user.click(screen.getByRole('button', {name: 'Menu'})); - await user.click(await screen.findByRole('menuitem', {name: /Change storage/})); + await switchToOther(); await waitFor(() => expect(confirmSpy).toHaveBeenCalled()); - expect(onChangeStorage).not.toHaveBeenCalled(); + expect(onOpenWorkspace).not.toHaveBeenCalled(); - // Accept the confirm: storage changes through. + // Accept the confirm: the switch goes through. confirmSpy.mockReturnValue(true); - await user.click(screen.getByRole('button', {name: 'Menu'})); - await user.click(await screen.findByRole('menuitem', {name: /Change storage/})); - await waitFor(() => expect(onChangeStorage).toHaveBeenCalled()); + await switchToOther(); + await waitFor(() => expect(onOpenWorkspace).toHaveBeenCalledWith('tauri:/other')); confirmSpy.mockRestore(); }); }); @@ -836,15 +824,7 @@ describe('Workspace — folder move', () => { dir.seedFile('Work/Note.md', 'w', 100); dir.seedFile('Archive/Other.md', 'a', 50); // makes "Archive" already exist → rename collides const store = new FileSystemNoteStore(asDirectoryHandle(dir)); - renderWithProviders( - <Workspace - store={store} - storageLabel="notes" - themePref="light" - onChangeThemePref={vi.fn()} - onChangeStorage={vi.fn()} - />, - ); + renderWithProviders(<Workspace store={store} {...workspaceProps()} />); await screen.findByRole('option', {name: /Note/}); fireEvent.keyDown(document, {key: '\\', code: 'Backslash', metaKey: true, shiftKey: true}); const work = await screen.findByRole('treeitem', {name: /Work/}); @@ -895,13 +875,7 @@ describe('Workspace — attachments survive StrictMode', () => { const user = userEvent.setup(); renderWithProviders( <StrictMode> - <Workspace - store={store} - storageLabel="notes" - themePref="light" - onChangeThemePref={vi.fn()} - onChangeStorage={vi.fn()} - /> + <Workspace store={store} {...workspaceProps()} /> </StrictMode>, ); diff --git a/src/components/Workspace.tsx b/src/components/Workspace.tsx index f08d01b..3f64309 100644 --- a/src/components/Workspace.tsx +++ b/src/components/Workspace.tsx @@ -11,9 +11,10 @@ import {useNoteHistory} from '../hooks/useNoteHistory'; import {useNoteNavigation} from '../hooks/useNoteNavigation'; import {useNoteSearch} from '../hooks/useNoteSearch'; import {useNotes} from '../hooks/useNotes'; +import type {WorkspaceInfo} from '../hooks/useNotesStorage'; import {useSettings} from '../hooks/useSettings'; import {useShortcuts} from '../hooks/useShortcuts'; -import {isTauri} from '../isTauri'; +import {isMainWindow, isTauri} from '../isTauri'; import {orderNotes} from '../storage/metadata'; import {dirname, sanitizeTitle, titleFromFileName} from '../storage/noteText'; import {exportNotes, importNotes} from '../storage/transfer'; @@ -34,17 +35,33 @@ import {ShortcutsDialog} from './ShortcutsDialog'; import {TopBar} from './TopBar'; import {TrashDialog} from './TrashDialog'; import {UpdateDialog} from './UpdateDialog'; +import {WorkspaceSwitcherDialog} from './WorkspaceSwitcherDialog'; import {type ThemePref} from './theme'; import './Workspace.css'; interface WorkspaceProps { store: NoteStore; + /** The active workspace's registry id (App remounts this component keyed on it). */ + workspaceId: string; /** Label for the active storage (folder name, or "In this browser"). */ storageLabel: string | null; + /** Known workspaces, most recently opened first — feeds the recents menu + ⌃R switcher. */ + workspaces: WorkspaceInfo[]; themePref: ThemePref; onChangeThemePref: (pref: ThemePref) => void; - onChangeStorage: () => void; + /** Switch this window to a workspace; false = it could not be opened (we stay put). */ + onOpenWorkspace: (id: string) => Promise<boolean>; + /** Desktop only: open a workspace in its own window. */ + onOpenWorkspaceInNewWindow: (id: string) => Promise<void>; + /** Drop a workspace from the recents registry. */ + onRemoveWorkspace: (id: string) => Promise<void>; + /** Re-read the registry (other windows may have opened workspaces since). */ + onRefreshWorkspaces: () => Promise<void>; + /** Open the folder picker (adds/opens a workspace in this window). */ + onOpenFolder: () => void; + /** Whether folder-on-disk workspaces are available (native app, or FSA in the browser). */ + supportsFolders: boolean; } /** @@ -56,18 +73,36 @@ interface WorkspaceProps { const SEARCH_DEBOUNCE_THRESHOLD = 500; const SEARCH_DEBOUNCE_MS = 120; -const SIDEBAR_KEY = 'gravity-notes:sidebar-collapsed'; -// Folders the user has explicitly expanded in the rail. The tree is collapsed by default, so this -// tracks the *exceptions* (empty = every folder collapsed) — the inverse of the old collapsed-set. -const EXPANDED_FOLDERS_KEY = 'gravity-notes:expanded-folders'; +// Per-workspace UI state (layout + rail selection) lives under workspace-namespaced keys, so each +// workspace keeps its own sidebar/rail arrangement across switches and windows. +const nsKey = (workspaceId: string, suffix: string) => `gravity-notes:${workspaceId}:${suffix}`; + +// The pre-workspace (un-namespaced) UI-state keys. Consumed once — as the defaults for the first +// workspace opened after the upgrade (the migrated one) — then deleted, so a later "set back to +// default" in that workspace can't fall through to a stale global value. +const LEGACY_SIDEBAR_KEY = 'gravity-notes:sidebar-collapsed'; +const LEGACY_EXPANDED_FOLDERS_KEY = 'gravity-notes:expanded-folders'; +const LEGACY_SELECTED_FOLDER_KEY = 'gravity-notes:selected-folder'; +const LEGACY_RAIL_OPEN_KEY = 'gravity-notes:rail-open'; // The pre-inversion key (a *collapsed* set, expanded-by-default). Its meaning flipped, so the old // value can't be reused; clear it once on load so it doesn't linger as dead localStorage. const LEGACY_COLLAPSED_FOLDERS_KEY = 'gravity-notes:collapsed-folders'; -const SELECTED_FOLDER_KEY = 'gravity-notes:selected-folder'; -const RAIL_OPEN_KEY = 'gravity-notes:rail-open'; -function loadSelectedFolder(): string | null { - return localStorage.getItem(SELECTED_FOLDER_KEY); +/** + * Read a per-workspace UI key, adopting (and consuming) the pre-workspace global value the first + * time nothing namespaced exists. Runs in state initializers — the write is idempotent, so a + * StrictMode double-init is harmless. + */ +function readWorkspaceKey(workspaceId: string, suffix: string, legacyKey: string): string | null { + const key = nsKey(workspaceId, suffix); + const value = localStorage.getItem(key); + if (value !== null) return value; + const legacy = localStorage.getItem(legacyKey); + if (legacy !== null) { + localStorage.setItem(key, legacy); + localStorage.removeItem(legacyKey); + } + return legacy; } /** Re-prefix a folder path (or note id) when its `from` ancestor folder moves/renames to `to`. */ @@ -75,9 +110,11 @@ function reprefixPath(path: string, from: string, to: string): string { return path === from || path.startsWith(`${from}/`) ? to + path.slice(from.length) : path; } -function loadExpandedFolders(): Set<string> { +function loadExpandedFolders(workspaceId: string): Set<string> { try { - const raw = JSON.parse(localStorage.getItem(EXPANDED_FOLDERS_KEY) ?? '[]'); + const raw = JSON.parse( + readWorkspaceKey(workspaceId, 'expanded-folders', LEGACY_EXPANDED_FOLDERS_KEY) ?? '[]', + ); return new Set( Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : [], ); @@ -94,10 +131,17 @@ let toastSeq = 0; export function Workspace({ store, + workspaceId, storageLabel, + workspaces, themePref, onChangeThemePref, - onChangeStorage, + onOpenWorkspace, + onOpenWorkspaceInNewWindow, + onRemoveWorkspace, + onRefreshWorkspaces, + onOpenFolder, + supportsFolders, }: WorkspaceProps) { const {add} = useToaster(); @@ -190,10 +234,15 @@ export function Workspace({ // The tree is collapsed by default; this persists the folders the user has explicitly expanded // (the exceptions). A toggle rebuilds the set immutably. - const [expandedFolders, setExpandedFolders] = useState<Set<string>>(loadExpandedFolders); + const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => + loadExpandedFolders(workspaceId), + ); useEffect(() => { - localStorage.setItem(EXPANDED_FOLDERS_KEY, JSON.stringify([...expandedFolders])); - }, [expandedFolders]); + localStorage.setItem( + nsKey(workspaceId, 'expanded-folders'), + JSON.stringify([...expandedFolders]), + ); + }, [workspaceId, expandedFolders]); // One-time cleanup of the orphaned pre-inversion key (its semantics flipped, so it's unusable). useEffect(() => localStorage.removeItem(LEGACY_COLLAPSED_FOLDERS_KEY), []); const toggleCollapse = useCallback((path: string) => { @@ -206,11 +255,13 @@ export function Workspace({ }, []); // The folder selected in the rail (null = All Notes), persisted across reloads. - const [selectedFolder, setSelectedFolder] = useState<string | null>(loadSelectedFolder); + const [selectedFolder, setSelectedFolder] = useState<string | null>(() => + readWorkspaceKey(workspaceId, 'selected-folder', LEGACY_SELECTED_FOLDER_KEY), + ); useEffect(() => { - if (selectedFolder === null) localStorage.removeItem(SELECTED_FOLDER_KEY); - else localStorage.setItem(SELECTED_FOLDER_KEY, selectedFolder); - }, [selectedFolder]); + if (selectedFolder === null) localStorage.removeItem(nsKey(workspaceId, 'selected-folder')); + else localStorage.setItem(nsKey(workspaceId, 'selected-folder'), selectedFolder); + }, [workspaceId, selectedFolder]); // A selected folder that no longer exists (deleted, or renamed elsewhere) falls back to All Notes. useEffect(() => { if (selectedFolder !== null && !notes.folders.includes(selectedFolder)) { @@ -220,10 +271,12 @@ export function Workspace({ // Whether the folder rail is shown. Off by default, so the app stays a 2-pane nvALT view // until you reach for folders; persisted across reloads. - const [railOpen, setRailOpen] = useState(() => localStorage.getItem(RAIL_OPEN_KEY) === 'true'); + const [railOpen, setRailOpen] = useState( + () => readWorkspaceKey(workspaceId, 'rail-open', LEGACY_RAIL_OPEN_KEY) === 'true', + ); useEffect(() => { - localStorage.setItem(RAIL_OPEN_KEY, String(railOpen)); - }, [railOpen]); + localStorage.setItem(nsKey(workspaceId, 'rail-open'), String(railOpen)); + }, [workspaceId, railOpen]); const toggleRail = useCallback(() => setRailOpen((open) => !open), []); // Drive list MODE (ranked search vs folder scope) off the debounced query, so the list flips in @@ -275,17 +328,21 @@ export function Workspace({ const [trashOpen, setTrashOpen] = useState(false); // Open the About dialog when the native "About Gravity Notes" menu item fires (the Rust menu - // handler emits `menu:about`). Desktop only; the event API is dynamically imported so it never - // enters the web bundle (mirrors the close-request listener in useNotes). + // handler emits `menu:about` to the FOCUSED window). Listen on the current window — a plain + // `listen()` registers the any-target scope, which also receives events aimed at other windows, + // so every window would open the dialog at once. Desktop only; the API is dynamically imported + // so it never enters the web bundle (mirrors the close-request listener in useNotes). useEffect(() => { if (!isTauri) return undefined; let unlisten: (() => void) | undefined; let disposed = false; - void import('@tauri-apps/api/event').then(({listen}) => - listen('menu:about', () => setAboutOpen(true)).then((fn) => { - if (disposed) fn(); - else unlisten = fn; - }), + void import('@tauri-apps/api/webviewWindow').then(({getCurrentWebviewWindow}) => + getCurrentWebviewWindow() + .listen('menu:about', () => setAboutOpen(true)) + .then((fn) => { + if (disposed) fn(); + else unlisten = fn; + }), ); return () => { disposed = true; @@ -299,7 +356,9 @@ export function Workspace({ const [updateDialogOpen, setUpdateDialogOpen] = useState(false); useEffect(() => { // Production-only so `tauri dev` doesn't prompt; manual checks still work in any build. - if (!updater.supported || !import.meta.env.PROD) return; + // Main-window-only so a workspace window doesn't fire a second concurrent check (each + // window is its own JS context — the updater's in-flight guard can't see across them). + if (!updater.supported || !import.meta.env.PROD || !isMainWindow()) return; void (async () => { const found = await updater.check({silent: true}); if (!found) return; @@ -361,10 +420,12 @@ export function Workspace({ window.addEventListener('scroll', onScroll, true); return () => window.removeEventListener('scroll', onScroll, true); }, []); - const [collapsed, setCollapsed] = useState(() => localStorage.getItem(SIDEBAR_KEY) === 'true'); + const [collapsed, setCollapsed] = useState( + () => readWorkspaceKey(workspaceId, 'sidebar-collapsed', LEGACY_SIDEBAR_KEY) === 'true', + ); useEffect(() => { - localStorage.setItem(SIDEBAR_KEY, String(collapsed)); - }, [collapsed]); + localStorage.setItem(nsKey(workspaceId, 'sidebar-collapsed'), String(collapsed)); + }, [workspaceId, collapsed]); const toggleCollapsed = useCallback(() => setCollapsed((c) => !c), []); // Transient overlay reveal of the collapsed sidebar (⌘⇧'); not persisted. Only meaningful @@ -466,27 +527,84 @@ export function Workspace({ [add], ); - // Change storage only after flushing any pending edit, so a keystroke inside the 500 ms - // autosave window isn't lost when the workspace unmounts. - const handleChangeStorage = useCallback(() => { + // Leave the current workspace only after flushing any pending edit, so a keystroke inside the + // 500 ms autosave window isn't lost when this component unmounts. If the flush couldn't land + // (an unresolved conflict still holds the edit), switching would silently discard that + // content — mirror the rename/move/trash conflict guards and make the user confirm the loss + // first. Uses flush()'s return value, not the `notes.conflict` state, which is stale in this + // closure right after the await (it would miss a conflict first surfaced BY this very flush). + const confirmLeaveSafe = useCallback(async () => { + const unresolvedConflict = await notes.flushPending(); + return ( + !unresolvedConflict || + window.confirm( + 'This note has an unresolved conflict with unsaved changes that will be lost if you switch workspaces. Continue?', + ) + ); + }, [notes]); + + // Switch this window to another workspace (from the recents menu, the ⌃R switcher, or the + // choice-screen recents). Flush-guarded; a failed open leaves the current workspace mounted. + const handleOpenWorkspace = useCallback( + (id: string) => { + void (async () => { + if (!(await confirmLeaveSafe())) return; + // Guard the whole open: a false result means "couldn't open" (folder gone), and a + // rejection (e.g. a dead permission handle) must surface a toast rather than become + // an unhandled promise rejection. + try { + const opened = await onOpenWorkspace(id); + if (!opened) { + onError( + 'Could not open that workspace — its folder may have moved or be unavailable.', + ); + } + } catch (err) { + onError(err instanceof Error ? err.message : 'Could not open that workspace.'); + } + })(); + }, + [confirmLeaveSafe, onOpenWorkspace, onError], + ); + + // Opening in a NEW window leaves this one untouched — no flush needed. + const handleOpenWorkspaceInNewWindow = useCallback( + (id: string) => { + onOpenWorkspaceInNewWindow(id).catch((err) => + onError(err instanceof Error ? err.message : 'Could not open a new window'), + ); + }, + [onOpenWorkspaceInNewWindow, onError], + ); + + const handleRemoveWorkspace = useCallback( + (id: string) => { + onRemoveWorkspace(id).catch((err) => + onError(err instanceof Error ? err.message : 'Could not update recent workspaces'), + ); + }, + [onRemoveWorkspace, onError], + ); + + // "Open Folder…" replaces THIS window's workspace with the picked folder, so it takes the same + // flush guard as a switch. + const handleOpenFolder = useCallback(() => { void (async () => { - // If the flush couldn't land (an unresolved conflict still holds the edit), switching - // stores would silently discard that content — mirror the rename/move/trash conflict - // guards and make the user confirm the loss first. Use flush()'s return value, not the - // `notes.conflict` state, which is stale in this closure right after the await (it would - // miss a conflict first surfaced BY this very flush). - const unresolvedConflict = await notes.flushPending(); - if ( - unresolvedConflict && - !window.confirm( - 'This note has an unresolved conflict with unsaved changes that will be lost if you switch storage. Continue?', - ) - ) { - return; - } - onChangeStorage(); + if (!(await confirmLeaveSafe())) return; + onOpenFolder(); + })(); + }, [confirmLeaveSafe, onOpenFolder]); + + // The ⌃R switcher. Refresh the registry BEFORE opening, so recents written by other windows are + // already in the list when the dialog seeds its pre-highlight — otherwise a late refresh could + // reorder the rows under a highlight the user already saw (opening the wrong workspace on ↵). + const [switcherOpen, setSwitcherOpen] = useState(false); + const openSwitcher = useCallback(() => { + void (async () => { + await onRefreshWorkspaces(); + setSwitcherOpen(true); })(); - }, [notes, onChangeStorage]); + }, [onRefreshWorkspaces]); const handleExport = useCallback(() => { void (async () => { @@ -825,6 +943,7 @@ export function Workspace({ deleteSelected: () => { if (nav.selectedId) listRef.current?.requestDelete(nav.selectedId); }, + openWorkspaces: openSwitcher, }); return ( @@ -843,7 +962,15 @@ export function Workspace({ /> <TopBar storageLabel={storageLabel} - onChangeStorage={handleChangeStorage} + workspaces={workspaces} + activeWorkspaceId={workspaceId} + isDesktop={isTauri} + supportsFolders={supportsFolders} + onOpenWorkspace={handleOpenWorkspace} + onOpenWorkspaceInNewWindow={handleOpenWorkspaceInNewWindow} + onOpenFolder={handleOpenFolder} + onOpenSwitcher={openSwitcher} + onMenuOpen={() => void onRefreshWorkspaces()} onExport={handleExport} onImport={handleImportClick} onManageAttachments={handleManageAttachments} @@ -1059,6 +1186,28 @@ export function Workspace({ onMove={handleMoveTo} onClose={() => setMovingNoteId(null)} /> + + <WorkspaceSwitcherDialog + open={switcherOpen} + workspaces={workspaces} + currentId={workspaceId} + isDesktop={isTauri} + supportsFolders={supportsFolders} + onOpen={(id) => { + setSwitcherOpen(false); + handleOpenWorkspace(id); + }} + onOpenInNewWindow={(id) => { + setSwitcherOpen(false); + handleOpenWorkspaceInNewWindow(id); + }} + onOpenFolder={() => { + setSwitcherOpen(false); + handleOpenFolder(); + }} + onRemove={handleRemoveWorkspace} + onClose={() => setSwitcherOpen(false)} + /> </div> </AttachmentsContext.Provider> ); diff --git a/src/components/WorkspaceSwitcherDialog.css b/src/components/WorkspaceSwitcherDialog.css new file mode 100644 index 0000000..4788e52 --- /dev/null +++ b/src/components/WorkspaceSwitcherDialog.css @@ -0,0 +1,113 @@ +/* ⌃R workspace switcher: a filter field over the recents list. Rows mirror the Move-to picker + (icon + name + quiet hints), so the two pickers read as the same control. */ +.ws-switch__list { + margin-top: 8px; + max-height: 320px; + overflow-y: auto; + border: 1px solid var(--g-color-line-generic); + border-radius: var(--g-border-radius-m, 6px); + padding: 4px 0; +} + +.ws-switch__empty { + padding: 16px 8px; + text-align: center; +} + +/* One workspace row: icon + name/path stack (+ a "current" hint or hover-revealed remove). */ +.ws-switch__row { + display: flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 3px 10px 3px 12px; + cursor: pointer; + user-select: none; +} + +/* The keyboard-highlighted (and hover-tracked) row. */ +.ws-switch__row_active { + background-color: var(--note-list-focus-bg); +} + +/* This window's current workspace: shown for orientation, not pickable. */ +.ws-switch__row_disabled { + cursor: default; + opacity: 0.5; +} + +.ws-switch__icon { + flex-shrink: 0; + color: var(--g-color-text-secondary); +} + +.ws-switch__text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.ws-switch__name { + min-width: 0; +} + +.ws-switch__path { + min-width: 0; + font-size: 12px; + line-height: 1.2; +} + +/* Quiet "current" tag on the disabled row. */ +.ws-switch__hint { + flex-shrink: 0; + color: var(--g-color-text-hint); + font-size: 12px; + line-height: 1; +} + +/* Remove-from-recents ✕, revealed on row hover/highlight (⌘⌫ is the keyboard path). */ +.ws-switch__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + border: 0; + background: transparent; + padding: 0; + border-radius: 3px; + cursor: pointer; + color: var(--g-color-text-hint); + opacity: 0; +} + +.ws-switch__row:hover .ws-switch__remove, +.ws-switch__row_active .ws-switch__remove { + opacity: 1; +} + +.ws-switch__remove:hover { + color: var(--g-color-text-primary); + background-color: var(--g-color-base-generic-hover); +} + +.ws-switch__match { + background-color: var(--g-color-base-warning-medium, rgba(255, 219, 77, 0.45)); + border-radius: 2px; + padding: 0 1px; +} + +/* The pinned "Open Folder…" action, set off from the recents above it. */ +.ws-switch__row_open-folder { + margin-top: 4px; + border-top: 1px solid var(--g-color-line-generic); +} + +/* The keyboard cheat-line under the list. */ +.ws-switch__keys { + display: block; + margin-top: 8px; + font-size: 12px; +} diff --git a/src/components/WorkspaceSwitcherDialog.test.tsx b/src/components/WorkspaceSwitcherDialog.test.tsx new file mode 100644 index 0000000..f9c150c --- /dev/null +++ b/src/components/WorkspaceSwitcherDialog.test.tsx @@ -0,0 +1,212 @@ +import {fireEvent, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {describe, expect, it, vi} from 'vitest'; + +import type {WorkspaceInfo} from '../hooks/useNotesStorage'; +import {renderWithProviders} from '../test/render'; + +import { + WorkspaceSwitcherDialog, + type WorkspaceSwitcherDialogProps, +} from './WorkspaceSwitcherDialog'; + +const WORKSPACES: WorkspaceInfo[] = [ + {id: 'tauri:/Users/me/Notes', backend: 'tauri-fs', name: 'Notes', path: '/Users/me/Notes'}, + {id: 'tauri:/Users/me/Work', backend: 'tauri-fs', name: 'Work', path: '/Users/me/Work'}, + {id: 'indexeddb', backend: 'indexeddb', name: 'In this app'}, +]; + +function setup(over: Partial<WorkspaceSwitcherDialogProps> = {}) { + const onOpen = vi.fn(); + const onOpenInNewWindow = vi.fn(); + const onOpenFolder = vi.fn(); + const onRemove = vi.fn(); + const onClose = vi.fn(); + const props: WorkspaceSwitcherDialogProps = { + open: true, + workspaces: WORKSPACES, + currentId: 'tauri:/Users/me/Notes', + isDesktop: true, + supportsFolders: true, + onOpen, + onOpenInNewWindow, + onOpenFolder, + onRemove, + onClose, + ...over, + }; + renderWithProviders(<WorkspaceSwitcherDialog {...props} />); + return {onOpen, onOpenInNewWindow, onOpenFolder, onRemove, onClose}; +} + +const filter = () => screen.getByRole('combobox', {name: 'Filter workspaces'}); +// Gravity's Dialog focus-trap drops keystrokes from userEvent.type in jsdom, so drive the controlled +// field with fireEvent.change (one shot) and keys with fireEvent.keyDown. Clicks use userEvent. +const type = (value: string) => fireEvent.change(filter(), {target: {value}}); +const press = (key: string, init: Record<string, unknown> = {}) => + fireEvent.keyDown(filter(), {key, ...init}); + +describe('WorkspaceSwitcherDialog', () => { + it('lists the workspaces with the current one badged and not pickable', async () => { + const user = userEvent.setup(); + const {onOpen} = setup(); + for (const name of ['Notes', 'Work', 'In this app']) { + expect(screen.getByRole('option', {name: new RegExp(name)})).toBeInTheDocument(); + } + const current = screen.getByRole('option', {name: /Notes/}); + expect(current).toHaveAttribute('aria-disabled', 'true'); + expect(current).toHaveTextContent('current'); + await user.click(current); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('synthesizes the in-browser row on the web — and never lets ⌘⌫ remove it', () => { + const {onRemove} = setup({ + workspaces: WORKSPACES.filter((ws) => ws.backend !== 'indexeddb'), + isDesktop: false, + }); + expect(screen.getByRole('option', {name: /In this browser/})).toBeInTheDocument(); + // Pre-highlight is Work (first selectable); the synthesized row is next. + press('ArrowDown'); + press('Backspace', {metaKey: true}); + expect(onRemove).not.toHaveBeenCalled(); + }); + + it('does not offer in-browser storage on desktop (folder-first)', () => { + setup({workspaces: WORKSPACES.filter((ws) => ws.backend !== 'indexeddb')}); + expect(screen.queryByRole('option', {name: /In this (app|browser)/})).toBeNull(); + }); + + it('still lists an in-app workspace that exists in the registry (data safety)', () => { + // Not offered ≠ hidden: someone who stored notes in-app before must keep reaching them. + setup(); + expect(screen.getByRole('option', {name: /In this app/})).toBeInTheDocument(); + }); + + it('opens a workspace on click', async () => { + const user = userEvent.setup(); + const {onOpen, onOpenInNewWindow} = setup(); + await user.click(screen.getByRole('option', {name: /Work/})); + expect(onOpen).toHaveBeenCalledWith('tauri:/Users/me/Work'); + expect(onOpenInNewWindow).not.toHaveBeenCalled(); + }); + + it('filters by name or path and opens the first match on Enter (typeahead)', () => { + const {onOpen} = setup(); + type('wor'); + expect(screen.queryByRole('option', {name: /In this app/})).not.toBeInTheDocument(); + press('Enter'); + expect(onOpen).toHaveBeenCalledWith('tauri:/Users/me/Work'); + }); + + it('orders others by recency with the current workspace last (before the action row)', () => { + setup(); + const texts = screen.getAllByRole('option').map((el) => el.textContent ?? ''); + // WORKSPACES recency: Notes (current) > Work > In this app. Others lead, current sinks. + expect(texts[0]).toContain('Work'); + expect(texts[1]).toContain('In this app'); + expect(texts[2]).toContain('Notes'); + expect(texts[2]).toContain('current'); + expect(texts[3]).toContain('Open Folder'); + }); + + it('pre-highlights the top row (most recent OTHER workspace), so ⌃R↵ ⌃R↵ ping-pongs', () => { + const {onOpen} = setup(); + // No arrows, no filter: Work — the top row — is highlighted on open; the current + // workspace sits at the bottom and never takes the highlight. + press('Enter'); + expect(onOpen).toHaveBeenCalledWith('tauri:/Users/me/Work'); + }); + + it('handles keys at the DOCUMENT level — navigation works wherever focus sits', () => { + const {onOpen, onOpenFolder} = setup(); + // Fire on <body>, NOT the input: in the live app the Dialog's focus manager can park + // focus on the dialog container (or a row's ✕), where an input-scoped handler goes + // silent — arrows/Enter must still work (regression for a live-only bug). + fireEvent.keyDown(document.body, {key: 'ArrowDown'}); + fireEvent.keyDown(document.body, {key: 'ArrowDown'}); + fireEvent.keyDown(document.body, {key: 'Enter'}); + expect(onOpenFolder).toHaveBeenCalledTimes(1); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('arrows walk the selectable rows down to the Open Folder action', () => { + const {onOpen, onOpenFolder} = setup(); + // Order: [Work] → In this app → Notes (current, skipped) → Open Folder…. + press('ArrowDown'); + press('ArrowDown'); + press('Enter'); + expect(onOpenFolder).toHaveBeenCalledTimes(1); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('⌘Enter opens the highlighted workspace in a new window on desktop', () => { + const {onOpen, onOpenInNewWindow} = setup(); + press('Enter', {metaKey: true}); + expect(onOpenInNewWindow).toHaveBeenCalledWith('tauri:/Users/me/Work'); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('⌘Enter falls back to a plain open on the web (no windows there)', () => { + const {onOpen, onOpenInNewWindow} = setup({isDesktop: false}); + press('Enter', {metaKey: true}); + expect(onOpen).toHaveBeenCalledWith('tauri:/Users/me/Work'); + expect(onOpenInNewWindow).not.toHaveBeenCalled(); + }); + + it('⌘⌫ removes the highlighted workspace from recents', () => { + const {onRemove} = setup(); + press('Backspace', {metaKey: true}); + expect(onRemove).toHaveBeenCalledWith('tauri:/Users/me/Work'); + }); + + it('never removes the Open Folder action row', () => { + const {onRemove} = setup({ + workspaces: WORKSPACES.filter((ws) => ws.backend !== 'indexeddb'), + }); + // Desktop, no in-app row: Notes (current, skipped) → [Work] → Open Folder…. + press('ArrowDown'); + press('Backspace', {metaKey: true}); + expect(onRemove).not.toHaveBeenCalled(); + }); + + it('removes via the row ✕ button without opening the workspace', async () => { + const user = userEvent.setup(); + const {onOpen, onRemove} = setup(); + await user.click(screen.getByRole('button', {name: 'Remove Work from recents'})); + expect(onRemove).toHaveBeenCalledWith('tauri:/Users/me/Work'); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('opens the folder picker from the Open Folder row on click', async () => { + const user = userEvent.setup(); + const {onOpen, onOpenFolder} = setup(); + await user.click(screen.getByRole('option', {name: /Open Folder/})); + expect(onOpenFolder).toHaveBeenCalledTimes(1); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('hides the Open Folder action when folder storage is unavailable', () => { + setup({supportsFolders: false, isDesktop: false}); + expect(screen.queryByRole('option', {name: /Open Folder/})).toBeNull(); + }); + + it('shows an empty hint when nothing matches — with Open Folder still reachable', () => { + const {onOpenFolder} = setup(); + type('zzz'); + expect(screen.getByText(/No workspaces match/)).toBeInTheDocument(); + // The action row sits outside the filter, so it survives as the only option… + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Open Folder…'); + // …and is the pre-highlighted target, so ↵ still does something useful. + press('Enter'); + expect(onOpenFolder).toHaveBeenCalledTimes(1); + }); + + it('Escape closes the switcher', () => { + const {onClose} = setup(); + press('Escape'); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/components/WorkspaceSwitcherDialog.tsx b/src/components/WorkspaceSwitcherDialog.tsx new file mode 100644 index 0000000..68c9341 --- /dev/null +++ b/src/components/WorkspaceSwitcherDialog.tsx @@ -0,0 +1,270 @@ +import {useEffect, useMemo, useRef, useState} from 'react'; + +import {Database, Folder, FolderPlus, Xmark} from '@gravity-ui/icons'; +import {Dialog, Icon, Text, TextInput} from '@gravity-ui/uikit'; + +import {useListboxNav} from '../hooks/useListboxNav'; +import type {WorkspaceInfo} from '../hooks/useNotesStorage'; + +import {highlightMatch} from './highlightMatch'; + +import './WorkspaceSwitcherDialog.css'; + +export interface WorkspaceSwitcherDialogProps { + open: boolean; + /** Known workspaces, most recently opened first (includes the current one). */ + workspaces: WorkspaceInfo[]; + /** The workspace this window is showing (badged, not pickable). */ + currentId: string | null; + /** Desktop shell: enables ⌘↵ open-in-new-window; hides the offered in-browser storage. */ + isDesktop: boolean; + /** Whether folder workspaces are available at all (shows the "Open Folder…" action row). */ + supportsFolders: boolean; + /** Open in this window. */ + onOpen: (id: string) => void; + /** Open in its own window (desktop only). */ + onOpenInNewWindow: (id: string) => void; + /** Pick a brand-new folder (opens in this window). */ + onOpenFolder: () => void; + /** Drop from the recents list (notes on disk are untouched). */ + onRemove: (id: string) => void; + onClose: () => void; +} + +/** One keyboard-navigable row of the switcher. */ +interface Row { + id: string; + name: string; + path?: string; + isBrowser: boolean; + /** The current workspace: shown for orientation, not pickable. */ + disabled: boolean; + /** + * The in-browser storage offered before it exists in the registry — opening it creates it. + * Not removable, and "new window" would find nothing to assign, so it always opens in place. + */ + synthesized: boolean; + /** The pinned "Open Folder…" action row (not a workspace; outside the filter). */ + openFolder: boolean; +} + +const OPEN_FOLDER_ID = 'open-folder'; + +export function WorkspaceSwitcherDialog({ + open, + workspaces, + currentId, + isDesktop, + supportsFolders, + onOpen, + onOpenInNewWindow, + onOpenFolder, + onRemove, + onClose, +}: WorkspaceSwitcherDialogProps) { + const [query, setQuery] = useState(''); + const inputRef = useRef<HTMLInputElement>(null); + + const ql = query.trim().toLowerCase(); + + const entries = useMemo<Row[]>(() => { + const toRow = (ws: WorkspaceInfo): Row => ({ + id: ws.id, + name: ws.name, + path: ws.path, + isBrowser: ws.backend === 'indexeddb', + disabled: ws.id === currentId, + synthesized: false, + openFolder: false, + }); + // OTHER workspaces first, by recency — so the top row is both the pre-highlighted ↵ + // target and the most recently used elsewhere, and ⌃R↵ ⌃R↵ ping-pongs between the two + // most recent workspaces. The current one sits at the bottom, badged, for orientation. + const rows = workspaces.filter((ws) => ws.id !== currentId).map(toRow); + // On the WEB, in-browser storage stays offered even before it exists in the registry. + // The desktop app is folder-first, so nothing is offered there — though an in-app + // workspace that already exists (someone actually stored notes in it) still lists, so + // no data is ever stranded. + if (!isDesktop && !workspaces.some((ws) => ws.backend === 'indexeddb')) { + rows.push({ + id: 'indexeddb', + name: 'In this browser', + isBrowser: true, + disabled: currentId === 'indexeddb', + synthesized: true, + openFolder: false, + }); + } + rows.push(...workspaces.filter((ws) => ws.id === currentId).map(toRow)); + const filtered = ql + ? rows.filter( + (row) => + row.name.toLowerCase().includes(ql) || + (row.path ? row.path.toLowerCase().includes(ql) : false), + ) + : rows; + // A pinned action row, deliberately OUTSIDE the filter: a new folder is always one + // ↓/↵ away, whatever was typed. + if (supportsFolders) { + filtered.push({ + id: OPEN_FOLDER_ID, + name: 'Open Folder…', + isBrowser: false, + disabled: false, + synthesized: false, + openFolder: true, + }); + } + return filtered; + }, [workspaces, currentId, isDesktop, supportsFolders, ql]); + + const commit = (row: Row | undefined, newWindow: boolean) => { + if (!row || row.disabled) return; + if (row.openFolder) { + onOpenFolder(); + return; + } + // A synthesized row exists nowhere yet, so a new window couldn't be assigned to it — + // open it in place (which registers it). + if (newWindow && isDesktop && !row.synthesized) onOpenInNewWindow(row.id); + else onOpen(row.id); + }; + + // Shared filter-list keyboard model (↑/↓ skip-disabled, ↵ commit, ⌘⌫ remove, Esc close) on a + // document listener — see useListboxNav for why it can't be input-scoped. + const {activeIndex, setActiveIndex, registerRow} = useListboxNav<Row>({ + open, + items: entries, + getKey: (row) => row.id, + isDisabled: (row) => row.disabled, + onEnter: (index, event) => commit(entries[index], event.metaKey), + onRemove: (index) => { + const row = entries[index]; + if (row && !row.disabled && !row.synthesized && !row.openFolder) onRemove(row.id); + }, + onClose, + }); + + // Reset the filter when the dialog opens, and focus it so you can type-to-narrow immediately. + useEffect(() => { + if (open) { + setQuery(''); + inputRef.current?.focus(); + } + }, [open]); + + // Keyboard-first highlight: the first selectable row is pre-highlighted — on open that's the + // most recent OTHER workspace (or the "Open Folder…" action when there's nothing else), so + // ⌃R → ↵ hops to the previous workspace without touching the mouse; while filtering it's the + // first match. Reads entries through a ref so this re-runs on open/filter changes only: a + // mid-session removal must not yank the highlight back to the top (the hook's clamp handles it). + const entriesRef = useRef(entries); + entriesRef.current = entries; + useEffect(() => { + if (!open) return; + setActiveIndex(entriesRef.current.findIndex((row) => !row.disabled)); + }, [open, ql, setActiveIndex]); + + const activeId = entries[activeIndex]?.id; + + const renderRow = (row: Row, index: number) => { + const active = index === activeIndex; + let glyph = Folder; + if (row.isBrowser) glyph = Database; + if (row.openFolder) glyph = FolderPlus; + return ( + // Combobox pattern: keyboard runs through the filter input (aria-activedescendant points + // at the active option), so options are mouse targets and aren't individually focusable. + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus + <div + key={row.id} + id={`ws-switch-opt-${row.id}`} + ref={(el) => registerRow(row.id, el)} + className={ + 'ws-switch__row' + + (active ? ' ws-switch__row_active' : '') + + (row.disabled ? ' ws-switch__row_disabled' : '') + + (row.openFolder ? ' ws-switch__row_open-folder' : '') + } + role="option" + aria-selected={active} + aria-disabled={row.disabled || undefined} + onClick={(event) => commit(row, event.metaKey)} + onMouseMove={() => { + if (!row.disabled && !active) setActiveIndex(index); + }} + > + <Icon className="ws-switch__icon" data={glyph} size={16} aria-hidden /> + <span className="ws-switch__text"> + <Text className="ws-switch__name" ellipsis> + {highlightMatch(row.name, ql, 'ws-switch__match')} + </Text> + {row.path ? ( + <Text className="ws-switch__path" color="hint" ellipsis> + {row.path} + </Text> + ) : null} + </span> + {row.disabled ? <span className="ws-switch__hint">current</span> : null} + {!row.disabled && !row.synthesized && !row.openFolder ? ( + <button + type="button" + className="ws-switch__remove" + aria-label={`Remove ${row.name} from recents`} + tabIndex={-1} + onClick={(event) => { + event.stopPropagation(); + onRemove(row.id); + }} + > + <Icon data={Xmark} size={12} /> + </button> + ) : null} + </div> + ); + }; + + return ( + // initialFocus hands the filter input straight to the Dialog's focus manager — otherwise + // it focuses the dialog container itself, racing (and beating) our own focus effect. + <Dialog + open={open} + onClose={onClose} + size="s" + disableBodyScrollLock + initialFocus={inputRef} + > + <Dialog.Header caption="Switch workspace" /> + <Dialog.Body> + <TextInput + controlRef={inputRef} + autoComplete={false} + placeholder="Filter workspaces…" + value={query} + onUpdate={setQuery} + controlProps={{ + role: 'combobox', + 'aria-expanded': true, + 'aria-controls': 'ws-switch-listbox', + 'aria-activedescendant': activeId ? `ws-switch-opt-${activeId}` : undefined, + 'aria-label': 'Filter workspaces', + }} + /> + <div className="ws-switch__list" id="ws-switch-listbox" role="listbox"> + {/* The action row sits outside the filter, so "no matches" is judged on the + workspace rows alone and the hint renders above the still-present action. */} + {ql && !entries.some((row) => !row.openFolder) ? ( + <div className="ws-switch__empty"> + <Text color="secondary">No workspaces match “{query.trim()}”</Text> + </div> + ) : null} + {entries.map(renderRow)} + </div> + <Text className="ws-switch__keys" color="hint"> + {isDesktop ? '↵ open · ⌘↵ new window · ⌘⌫ remove' : '↵ open · ⌘⌫ remove'} + </Text> + </Dialog.Body> + <Dialog.Footer textButtonCancel="Cancel" onClickButtonCancel={onClose} /> + </Dialog> + ); +} diff --git a/src/components/highlightMatch.tsx b/src/components/highlightMatch.tsx new file mode 100644 index 0000000..b2b22da --- /dev/null +++ b/src/components/highlightMatch.tsx @@ -0,0 +1,19 @@ +import type {ReactNode} from 'react'; + +/** + * Wrap the first case-insensitive occurrence of `query` in `name` with a highlight `<mark>`. + * Shared by the filter-list dialogs (workspace switcher, move-to picker); each passes its own + * `<mark>` className so the highlight tint matches the surrounding dialog. + */ +export function highlightMatch(name: string, query: string, className: string): ReactNode { + if (!query) return name; + const idx = name.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return name; + return ( + <> + {name.slice(0, idx)} + <mark className={className}>{name.slice(idx, idx + query.length)}</mark> + {name.slice(idx + query.length)} + </> + ); +} diff --git a/src/hooks/useListboxNav.ts b/src/hooks/useListboxNav.ts new file mode 100644 index 0000000..9befab6 --- /dev/null +++ b/src/hooks/useListboxNav.ts @@ -0,0 +1,138 @@ +import {type Dispatch, type SetStateAction, useEffect, useRef, useState} from 'react'; + +export interface ListboxNav { + /** The highlighted row index (`-1` = nothing highlighted). */ + activeIndex: number; + /** Set the highlight directly (callers own the pre-highlight / typeahead seeding). */ + setActiveIndex: Dispatch<SetStateAction<number>>; + /** Ref callback for each rendered row, so the active row can be scrolled into view. */ + registerRow: (key: string, el: HTMLElement | null) => void; +} + +interface Options<T> { + /** Whether the owning dialog is open — the keydown listener is bound only while true. */ + open: boolean; + /** The current (already-filtered) rows, most stable identity possible (a useMemo). */ + items: T[]; + /** Stable key per row (used for scroll-into-view lookup). */ + getKey: (item: T) => string; + /** Rows that can't be committed/removed and are skipped by arrow navigation. */ + isDisabled: (item: T) => boolean; + /** Commit the row at `index` (↵). The raw event is passed so ⌘↵ can branch. */ + onEnter: (index: number, event: KeyboardEvent) => void; + /** ⌘⌫ on the row at `index`. Omit to disable removal. */ + onRemove?: (index: number) => void; + /** Esc. */ + onClose: () => void; +} + +/** + * The shared keyboard model for a filter-over-listbox dialog — the ⌃R workspace switcher and the + * ⌘⇧M move-to picker. Owns: a DOCUMENT-level keydown listener while open (↑/↓ move, ↵ commit, + * ⌘⌫ remove, Esc close), skip-disabled highlight navigation with a range/disabled clamp when the + * list changes, and scroll-the-active-row-into-view. Callers own filtering, rendering, and the + * pre-highlight/typeahead seeding (via `setActiveIndex`). + * + * The listener is on `document`, not the filter input, because Gravity's `Dialog` wraps its content + * in a floating-ui FocusManager that can park focus on the dialog CONTAINER (or a row's ✕ button), + * where an input-scoped `onKeyDown` goes silent — arrows/Enter dead. Pair with `initialFocus` on the + * Dialog so type-to-filter still lands in the input. + */ +export function useListboxNav<T>({ + open, + items, + getKey, + isDisabled, + onEnter, + onRemove, + onClose, +}: Options<T>): ListboxNav { + const [activeIndex, setActiveIndex] = useState(-1); + const rowRefs = useRef<Map<string, HTMLElement>>(new Map()); + + const registerRow = useRef((key: string, el: HTMLElement | null) => { + if (el) rowRefs.current.set(key, el); + else rowRefs.current.delete(key); + }).current; + + // Keep the highlight on a SELECTABLE row when the list changes (e.g. a removal shifts a + // disabled row under the cursor). An intentional "nothing highlighted" (-1) is left as-is; a + // landed-on-disabled or fell-off-the-end index snaps to the nearest selectable (searching down + // from the old position, then up). + useEffect(() => { + setActiveIndex((cur) => { + if (cur < 0) return -1; + if (cur < items.length && !isDisabled(items[cur])) return cur; + for (let i = Math.min(cur, items.length - 1); i >= 0; i--) { + if (!isDisabled(items[i])) return i; + } + for (let i = 0; i < items.length; i++) { + if (!isDisabled(items[i])) return i; + } + return -1; + }); + // Re-runs on the items identity; getKey/isDisabled are treated as stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]); + + // Keep the active row in view as it moves. + useEffect(() => { + if (!open) return; + const item = items[activeIndex]; + if (item) rowRefs.current.get(getKey(item))?.scrollIntoView?.({block: 'nearest'}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeIndex, items, open]); + + // Step the highlight to the next selectable row in `delta` direction (clamped, skips disabled). + // From "nothing highlighted" (-1), ArrowDown lands on the first selectable row, ArrowUp the last. + const move = (delta: number) => { + setActiveIndex((cur) => { + let start = cur; + if (cur < 0) start = delta > 0 ? -1 : items.length; + for (let i = start + delta; i >= 0 && i < items.length; i += delta) { + if (!isDisabled(items[i])) return i; + } + return cur; + }); + }; + + // Rebound every render (via the ref) so the once-bound document listener reads fresh state. + const handleKey = (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + move(1); + break; + case 'ArrowUp': + event.preventDefault(); + move(-1); + break; + case 'Enter': + event.preventDefault(); + onEnter(activeIndex, event); + break; + case 'Backspace': + if (onRemove && event.metaKey) { + event.preventDefault(); + onRemove(activeIndex); + } + break; + case 'Escape': + // The Modal's own dismiss also closes on Escape; keeping it here means the behavior + // survives even if that path is ever configured away. onClose is idempotent. + event.preventDefault(); + onClose(); + break; + } + }; + const handleKeyRef = useRef(handleKey); + handleKeyRef.current = handleKey; + useEffect(() => { + if (!open) return undefined; + const listener = (event: KeyboardEvent) => handleKeyRef.current(event); + document.addEventListener('keydown', listener); + return () => document.removeEventListener('keydown', listener); + }, [open]); + + return {activeIndex, setActiveIndex, registerRow}; +} diff --git a/src/hooks/useNotes.ts b/src/hooks/useNotes.ts index 38effb1..e52606e 100644 --- a/src/hooks/useNotes.ts +++ b/src/hooks/useNotes.ts @@ -1,6 +1,6 @@ import {useCallback, useEffect, useRef, useState} from 'react'; -import {isTauri} from '../isTauri'; +import {isMainWindow, isTauri} from '../isTauri'; import { DEFAULT_METADATA, reconcile, @@ -1104,9 +1104,14 @@ export function useNotes(store: NoteStore, onError: (message: string) => void): let disposed = false; void import('@tauri-apps/api/window').then(({getCurrentWindow}) => { void getCurrentWindow() - .onCloseRequested(async () => { + .onCloseRequested(async (event) => { await flushMetadata(); await flush(); + // The MAIN window is never destroyed — the Rust shell hides it on close (macOS + // convention), so stop the JS wrapper from destroying it after this handler. + // Workspace (ws-N) windows fall through: the wrapper destroys them once the + // flush above has landed — exactly the flush-then-close ordering we want. + if (isMainWindow()) event.preventDefault(); }) .then((fn) => { if (disposed) fn(); diff --git a/src/hooks/useNotesStorage.probe.test.tsx b/src/hooks/useNotesStorage.probe.test.tsx index 28cadad..910e2dd 100644 --- a/src/hooks/useNotesStorage.probe.test.tsx +++ b/src/hooks/useNotesStorage.probe.test.tsx @@ -9,40 +9,75 @@ vi.hoisted(() => { }); const listMock = vi.fn(); +const invokeMock = vi.fn(); -vi.mock('../storage/handlePersistence', () => ({ - loadBackend: vi.fn(), - loadDirHandle: vi.fn(), - loadFolderPath: vi.fn(), - queryPermission: vi.fn(), - requestPermission: vi.fn(), - saveDirHandle: vi.fn(), - saveBackend: vi.fn(), - saveFolderPath: vi.fn(), - clearStorageChoice: vi.fn(), -})); +vi.mock('../storage/workspaceRegistry', async (importOriginal) => { + const actual = await importOriginal<typeof import('../storage/workspaceRegistry')>(); + return { + ...actual, + loadWorkspaces: vi.fn(), + loadLastActiveId: vi.fn(), + touchWorkspace: vi.fn(), + removeWorkspace: vi.fn(), + clearLastActive: vi.fn(), + findFsaEntry: vi.fn(), + queryPermission: vi.fn(), + requestPermission: vi.fn(), + }; +}); vi.mock('../storage/tauriStore', () => ({ - TauriNoteStore: vi.fn().mockImplementation(() => ({list: listMock})), + // Each instance probes through the shared spy WITH its folder path, so per-folder behavior + // (workspace A reads fine, workspace B hangs) is scriptable from a test. + TauriNoteStore: vi.fn().mockImplementation((dir: string) => ({list: () => listMock(dir)})), +})); + +// The hook loads Tauri APIs via dynamic import; vitest intercepts those too. +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({setTitle: vi.fn(async () => {})}), })); import { - clearStorageChoice, - loadBackend, - loadDirHandle, - loadFolderPath, -} from '../storage/handlePersistence'; + type WorkspaceEntry, + clearLastActive, + loadLastActiveId, + loadWorkspaces, + touchWorkspace, +} from '../storage/workspaceRegistry'; import {useNotesStorage} from './useNotesStorage'; const PROBE_TIMEOUT_MS = 10_000; +const HUGE: WorkspaceEntry = { + id: 'tauri:/Users/me/Huge', + backend: 'tauri-fs', + name: 'Huge', + lastOpenedAt: 2, + path: '/Users/me/Huge', +}; +const OTHER: WorkspaceEntry = { + id: 'tauri:/Users/me/Other', + backend: 'tauri-fs', + name: 'Other', + lastOpenedAt: 1, + path: '/Users/me/Other', +}; + beforeEach(() => { vi.clearAllMocks(); - vi.mocked(loadBackend).mockResolvedValue('tauri-fs'); - vi.mocked(loadDirHandle).mockResolvedValue(undefined); - vi.mocked(loadFolderPath).mockResolvedValue('/Users/me/Huge'); - vi.mocked(clearStorageChoice).mockResolvedValue(); + vi.mocked(loadWorkspaces).mockResolvedValue([HUGE, OTHER]); + vi.mocked(loadLastActiveId).mockResolvedValue(HUGE.id); + vi.mocked(touchWorkspace).mockResolvedValue(); + vi.mocked(clearLastActive).mockResolvedValue(); + invokeMock.mockImplementation(async (cmd: unknown) => { + if (cmd === 'window_workspace') return null; // the main window has no assignment + if (cmd === 'focus_workspace_window') return false; + return undefined; + }); }); afterAll(() => { @@ -50,15 +85,20 @@ afterAll(() => { }); describe('useNotesStorage — Tauri folder probe', () => { - it('lands ready when the remembered folder reads quickly', async () => { + it('lands ready when the last-active folder reads quickly', async () => { listMock.mockResolvedValue([]); const {result} = renderHook(() => useNotesStorage()); await waitFor(() => expect(result.current.state).toBe('ready')); expect(result.current.backend).toBe('tauri-fs'); - expect(vi.mocked(clearStorageChoice)).not.toHaveBeenCalled(); + expect(result.current.storageLabel).toBe('Huge'); + expect(vi.mocked(clearLastActive)).not.toHaveBeenCalled(); + // The window registers its workspace with the shell (drives focus-if-open). + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith('set_window_workspace', {wsId: HUGE.id}), + ); }); - it('times out a hanging probe → forgets the folder and returns to the picker', async () => { + it('times out a hanging probe → forgets the launch pointer and returns to the picker', async () => { vi.useFakeTimers(); listMock.mockReturnValue(new Promise(() => {})); // never settles (huge/strange folder) try { @@ -68,18 +108,88 @@ describe('useNotesStorage — Tauri folder probe', () => { }); expect(result.current.state).toBe('choosing'); expect(result.current.error).toContain('took too long'); - // The unusable folder is forgotten so the next launch doesn't re-hang on it. - expect(vi.mocked(clearStorageChoice)).toHaveBeenCalledTimes(1); + // Only the launch POINTER is forgotten (next launch won't re-hang); the registry + // entry survives, so the folder is still reachable from recents. + expect(vi.mocked(clearLastActive)).toHaveBeenCalledTimes(1); } finally { vi.useRealTimers(); } }); - it('keeps the folder on a plain read error (may be a transient unplugged drive)', async () => { + it('keeps the launch pointer on a plain read error (may be a transient unplugged drive)', async () => { listMock.mockRejectedValue(new Error('No such file or directory')); const {result} = renderHook(() => useNotesStorage()); await waitFor(() => expect(result.current.state).toBe('choosing')); expect(result.current.error).toBe('No such file or directory'); - expect(vi.mocked(clearStorageChoice)).not.toHaveBeenCalled(); + expect(vi.mocked(clearLastActive)).not.toHaveBeenCalled(); + }); +}); + +describe('useNotesStorage — desktop window/workspace wiring', () => { + it('a window assignment from the shell beats the last-active pointer', async () => { + listMock.mockResolvedValue([]); + invokeMock.mockImplementation(async (cmd: unknown) => { + if (cmd === 'window_workspace') return OTHER.id; // a freshly-created ws-window + if (cmd === 'focus_workspace_window') return false; + return undefined; + }); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + expect(result.current.activeWorkspaceId).toBe(OTHER.id); + expect(result.current.storageLabel).toBe('Other'); + }); + + it('openWorkspace() focuses the other window instead of switching when one already shows it', async () => { + listMock.mockResolvedValue([]); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + invokeMock.mockImplementation(async (cmd: unknown) => { + if (cmd === 'focus_workspace_window') return true; // another window has it + return undefined; + }); + + let opened = false; + await act(async () => { + opened = await result.current.openWorkspace(OTHER.id); + }); + + expect(opened).toBe(true); + // This window stays on its own workspace — the other window was focused instead. + expect(result.current.activeWorkspaceId).toBe(HUGE.id); + expect(invokeMock).toHaveBeenCalledWith('focus_workspace_window', {wsId: OTHER.id}); + }); + + it('a failed switch probe leaves the current workspace mounted and resolves false', async () => { + listMock.mockImplementation(async (dir: string) => { + if (dir === OTHER.path) throw new Error('unreadable'); + return []; + }); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + + let opened = true; + await act(async () => { + opened = await result.current.openWorkspace(OTHER.id); + }); + + expect(opened).toBe(false); + expect(result.current.state).toBe('ready'); + expect(result.current.activeWorkspaceId).toBe(HUGE.id); + expect(result.current.storageLabel).toBe('Huge'); + }); + + it('openInNewWindow() asks the shell to open (or focus) a workspace window', async () => { + listMock.mockResolvedValue([]); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + + await act(async () => { + await result.current.openInNewWindow(OTHER.id); + }); + + expect(invokeMock).toHaveBeenCalledWith('open_workspace_window', { + wsId: OTHER.id, + title: 'Other — Gravity Notes', + }); }); }); diff --git a/src/hooks/useNotesStorage.test.tsx b/src/hooks/useNotesStorage.test.tsx index e285fc3..926bda5 100644 --- a/src/hooks/useNotesStorage.test.tsx +++ b/src/hooks/useNotesStorage.test.tsx @@ -1,85 +1,116 @@ import {act, renderHook, waitFor} from '@testing-library/react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -vi.mock('../storage/handlePersistence', () => ({ - loadBackend: vi.fn(), - loadDirHandle: vi.fn(), - queryPermission: vi.fn(), - requestPermission: vi.fn(), - saveDirHandle: vi.fn(), - saveBackend: vi.fn(), - clearStorageChoice: vi.fn(), -})); +vi.mock('../storage/workspaceRegistry', async (importOriginal) => { + const actual = await importOriginal<typeof import('../storage/workspaceRegistry')>(); + return { + ...actual, // keep the pure helpers (workspaceIdForPath, folderNameFromPath) real + loadWorkspaces: vi.fn(), + loadLastActiveId: vi.fn(), + touchWorkspace: vi.fn(), + removeWorkspace: vi.fn(), + clearLastActive: vi.fn(), + findFsaEntry: vi.fn(), + queryPermission: vi.fn(), + requestPermission: vi.fn(), + }; +}); import { - clearStorageChoice, - loadBackend, - loadDirHandle, + type WorkspaceEntry, + clearLastActive, + findFsaEntry, + loadLastActiveId, + loadWorkspaces, queryPermission, + removeWorkspace, requestPermission, - saveBackend, - saveDirHandle, -} from '../storage/handlePersistence'; + touchWorkspace, +} from '../storage/workspaceRegistry'; import {useNotesStorage} from './useNotesStorage'; const fakeHandle = {name: 'notes'} as unknown as FileSystemDirectoryHandle; +const fsaEntry: WorkspaceEntry = { + id: 'fsa:legacy', + backend: 'filesystem', + name: 'notes', + lastOpenedAt: 2, + handle: fakeHandle, +}; +const browserEntry: WorkspaceEntry = { + id: 'indexeddb', + backend: 'indexeddb', + name: 'Browser storage', + lastOpenedAt: 1, +}; + beforeEach(() => { vi.clearAllMocks(); - vi.mocked(loadBackend).mockResolvedValue(undefined); - vi.mocked(loadDirHandle).mockResolvedValue(undefined); + vi.mocked(loadWorkspaces).mockResolvedValue([]); + vi.mocked(loadLastActiveId).mockResolvedValue(undefined); + vi.mocked(touchWorkspace).mockResolvedValue(); + vi.mocked(removeWorkspace).mockResolvedValue(); + vi.mocked(clearLastActive).mockResolvedValue(); + vi.mocked(findFsaEntry).mockResolvedValue(undefined); vi.mocked(queryPermission).mockResolvedValue('granted'); vi.mocked(requestPermission).mockResolvedValue(true); - vi.mocked(saveDirHandle).mockResolvedValue(); - vi.mocked(saveBackend).mockResolvedValue(); - vi.mocked(clearStorageChoice).mockResolvedValue(); }); describe('useNotesStorage — bootstrap', () => { - it('shows the choice screen when nothing is stored', async () => { + it('shows the choice screen when the registry is empty', async () => { const {result} = renderHook(() => useNotesStorage()); await waitFor(() => expect(result.current.state).toBe('choosing')); expect(result.current.store).toBeNull(); + expect(result.current.workspaces).toEqual([]); }); - it('restores the in-browser backend', async () => { - vi.mocked(loadBackend).mockResolvedValue('indexeddb'); + it('restores the last-active in-browser workspace', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([browserEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('indexeddb'); const {result} = renderHook(() => useNotesStorage()); await waitFor(() => expect(result.current.state).toBe('ready')); expect(result.current.backend).toBe('indexeddb'); expect(result.current.storageLabel).toBe('In this browser'); + expect(result.current.activeWorkspaceId).toBe('indexeddb'); expect(result.current.store).not.toBeNull(); + expect(vi.mocked(touchWorkspace)).toHaveBeenCalledWith( + expect.objectContaining({id: 'indexeddb'}), + ); }); - it('restores a granted file-system folder', async () => { - vi.mocked(loadBackend).mockResolvedValue('filesystem'); - vi.mocked(loadDirHandle).mockResolvedValue(fakeHandle); + it('restores a granted file-system workspace', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([fsaEntry, browserEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('fsa:legacy'); const {result} = renderHook(() => useNotesStorage()); await waitFor(() => expect(result.current.state).toBe('ready')); expect(result.current.backend).toBe('filesystem'); expect(result.current.storageLabel).toBe('notes'); - expect(result.current.store).not.toBeNull(); + expect(result.current.workspaces.map((ws) => ws.id)).toEqual(['fsa:legacy', 'indexeddb']); }); - it('asks to re-grant permission when the folder is remembered but not granted', async () => { - vi.mocked(loadBackend).mockResolvedValue('filesystem'); - vi.mocked(loadDirHandle).mockResolvedValue(fakeHandle); + it('asks to re-grant permission on bootstrap without prompting (no user gesture yet)', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([fsaEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('fsa:legacy'); vi.mocked(queryPermission).mockResolvedValue('prompt'); const {result} = renderHook(() => useNotesStorage()); await waitFor(() => expect(result.current.state).toBe('needs-permission')); + expect(result.current.storageLabel).toBe('notes'); + expect(vi.mocked(requestPermission)).not.toHaveBeenCalled(); }); - it('treats a stored handle with no backend flag as a file-system user (back-compat)', async () => { - vi.mocked(loadBackend).mockResolvedValue(undefined); - vi.mocked(loadDirHandle).mockResolvedValue(fakeHandle); + it('falls back to the choice screen when last-active points at a removed entry', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([browserEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('tauri:/gone'); const {result} = renderHook(() => useNotesStorage()); - await waitFor(() => expect(result.current.state).toBe('ready')); - expect(result.current.backend).toBe('filesystem'); + await waitFor(() => expect(result.current.state).toBe('choosing')); + // The recents list still shows what exists, so the gate isn't a dead end. + expect(result.current.workspaces.map((ws) => ws.id)).toEqual(['indexeddb']); }); it('falls back to the choice screen with an error when restore throws', async () => { - vi.mocked(loadBackend).mockRejectedValue(new Error('IDB blocked')); + vi.mocked(loadWorkspaces).mockRejectedValue(new Error('IDB blocked')); const {result} = renderHook(() => useNotesStorage()); await waitFor(() => expect(result.current.state).toBe('choosing')); expect(result.current.error).toBe('IDB blocked'); @@ -87,7 +118,7 @@ describe('useNotesStorage — bootstrap', () => { }); describe('useNotesStorage — actions', () => { - it('useBrowserStorage() switches to a ready in-browser store', async () => { + it('useBrowserStorage() becomes ready and registers the workspace', async () => { const {result} = renderHook(() => useNotesStorage()); await waitFor(() => expect(result.current.state).toBe('choosing')); await act(async () => { @@ -95,10 +126,14 @@ describe('useNotesStorage — actions', () => { }); expect(result.current.state).toBe('ready'); expect(result.current.backend).toBe('indexeddb'); - expect(vi.mocked(saveBackend)).toHaveBeenCalledWith('indexeddb'); + await waitFor(() => + expect(vi.mocked(touchWorkspace)).toHaveBeenCalledWith( + expect.objectContaining({id: 'indexeddb', backend: 'indexeddb'}), + ), + ); }); - it('pickFolder() opens a folder and becomes ready', async () => { + it('pickFolder() opens a folder, minting a new registry entry', async () => { Object.defineProperty(window, 'showDirectoryPicker', { configurable: true, value: vi.fn().mockResolvedValue(fakeHandle), @@ -110,12 +145,39 @@ describe('useNotesStorage — actions', () => { }); expect(result.current.state).toBe('ready'); expect(result.current.backend).toBe('filesystem'); - expect(vi.mocked(saveBackend)).toHaveBeenCalledWith('filesystem'); - expect(vi.mocked(saveDirHandle)).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(vi.mocked(touchWorkspace)).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/^fsa:/), + backend: 'filesystem', + name: 'notes', + }), + ), + ); + }); + + it('pickFolder() dedupes a re-picked folder onto its existing entry', async () => { + Object.defineProperty(window, 'showDirectoryPicker', { + configurable: true, + value: vi.fn().mockResolvedValue(fakeHandle), + }); + vi.mocked(findFsaEntry).mockResolvedValue(fsaEntry); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('choosing')); + await act(async () => { + await result.current.pickFolder(); + }); + expect(result.current.activeWorkspaceId).toBe('fsa:legacy'); + await waitFor(() => + expect(vi.mocked(touchWorkspace)).toHaveBeenCalledWith( + expect.objectContaining({id: 'fsa:legacy'}), + ), + ); }); - it('reset() clears the choice and returns to the choice screen', async () => { - vi.mocked(loadBackend).mockResolvedValue('indexeddb'); + it('reset() returns to the choice screen, forgets last-active, but keeps the registry', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([browserEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('indexeddb'); const {result} = renderHook(() => useNotesStorage()); await waitFor(() => expect(result.current.state).toBe('ready')); await act(async () => { @@ -123,6 +185,141 @@ describe('useNotesStorage — actions', () => { }); expect(result.current.state).toBe('choosing'); expect(result.current.store).toBeNull(); - expect(vi.mocked(clearStorageChoice)).toHaveBeenCalledTimes(1); + expect(result.current.activeWorkspaceId).toBeNull(); + // The registry (recents) is kept — nothing is removed… + expect(vi.mocked(removeWorkspace)).not.toHaveBeenCalled(); + // …but the launch pointer is cleared, so the next relaunch lands on the choice screen + // instead of re-restoring (and re-gating) the folder the user stepped away from. + expect(vi.mocked(clearLastActive)).toHaveBeenCalledTimes(1); + }); +}); + +describe('useNotesStorage — workspace switching', () => { + it('openWorkspace() swaps to another known workspace in place', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([browserEntry, fsaEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('indexeddb'); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + const before = result.current.store; + + let opened = false; + await act(async () => { + opened = await result.current.openWorkspace('fsa:legacy'); + }); + + expect(opened).toBe(true); + expect(result.current.state).toBe('ready'); + expect(result.current.activeWorkspaceId).toBe('fsa:legacy'); + expect(result.current.backend).toBe('filesystem'); + expect(result.current.store).not.toBe(before); + }); + + it('openWorkspace() to the current workspace is a no-op success', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([browserEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('indexeddb'); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + const before = result.current.store; + vi.mocked(touchWorkspace).mockClear(); + + let opened = false; + await act(async () => { + opened = await result.current.openWorkspace('indexeddb'); + }); + + expect(opened).toBe(true); + expect(result.current.store).toBe(before); // same instance — no remount churn + expect(vi.mocked(touchWorkspace)).not.toHaveBeenCalled(); + }); + + it('openWorkspace("indexeddb") works even before the entry exists (synthesized row)', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([fsaEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('fsa:legacy'); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + + let opened = false; + await act(async () => { + opened = await result.current.openWorkspace('indexeddb'); + }); + + expect(opened).toBe(true); + expect(result.current.backend).toBe('indexeddb'); + await waitFor(() => + expect(vi.mocked(touchWorkspace)).toHaveBeenCalledWith( + expect.objectContaining({id: 'indexeddb'}), + ), + ); + }); + + it('openWorkspace() resolves false for an unknown id, leaving the current workspace mounted', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([browserEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('indexeddb'); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + + let opened = true; + await act(async () => { + opened = await result.current.openWorkspace('tauri:/nope'); + }); + + expect(opened).toBe(false); + expect(result.current.state).toBe('ready'); + expect(result.current.activeWorkspaceId).toBe('indexeddb'); + }); + + it('switching to an ungranted FSA workspace tries the prompt inline (user gesture)', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([browserEntry, fsaEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('indexeddb'); + vi.mocked(queryPermission).mockResolvedValue('prompt'); + vi.mocked(requestPermission).mockResolvedValue(true); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + + await act(async () => { + await result.current.openWorkspace('fsa:legacy'); + }); + + expect(vi.mocked(requestPermission)).toHaveBeenCalledWith(fakeHandle); + expect(result.current.state).toBe('ready'); + expect(result.current.backend).toBe('filesystem'); + }); + + it('routes to the permission gate when the inline prompt is denied, then grants from it', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([browserEntry, fsaEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('indexeddb'); + vi.mocked(queryPermission).mockResolvedValue('prompt'); + vi.mocked(requestPermission).mockResolvedValue(false); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + + await act(async () => { + await result.current.openWorkspace('fsa:legacy'); + }); + expect(result.current.state).toBe('needs-permission'); + expect(result.current.storageLabel).toBe('notes'); + + // The user clicks "Grant access" — this time the browser grants it. + vi.mocked(requestPermission).mockResolvedValue(true); + await act(async () => { + await result.current.grantPermission(); + }); + expect(result.current.state).toBe('ready'); + expect(result.current.activeWorkspaceId).toBe('fsa:legacy'); + }); + + it('removeWorkspace() forgets the entry and refreshes the list', async () => { + vi.mocked(loadWorkspaces).mockResolvedValue([browserEntry, fsaEntry]); + vi.mocked(loadLastActiveId).mockResolvedValue('indexeddb'); + const {result} = renderHook(() => useNotesStorage()); + await waitFor(() => expect(result.current.state).toBe('ready')); + + vi.mocked(loadWorkspaces).mockResolvedValue([browserEntry]); + await act(async () => { + await result.current.removeWorkspace('fsa:legacy'); + }); + + expect(vi.mocked(removeWorkspace)).toHaveBeenCalledWith('fsa:legacy'); + expect(result.current.workspaces.map((ws) => ws.id)).toEqual(['indexeddb']); }); }); diff --git a/src/hooks/useNotesStorage.ts b/src/hooks/useNotesStorage.ts index 04a5a7c..e31e524 100644 --- a/src/hooks/useNotesStorage.ts +++ b/src/hooks/useNotesStorage.ts @@ -2,26 +2,28 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import {isTauri} from '../isTauri'; import {FileSystemNoteStore} from '../storage/fileSystemStore'; +import {IndexedDbNoteStore} from '../storage/indexedDbStore'; +import {TauriNoteStore} from '../storage/tauriStore'; +import type {NoteStore} from '../storage/types'; import { type StorageBackend, - clearStorageChoice, - loadBackend, - loadDirHandle, - loadFolderPath, + type WorkspaceEntry, + clearLastActive, + findFsaEntry, + folderNameFromPath, + loadLastActiveId, + loadWorkspaces, queryPermission, + removeWorkspace as removeWorkspaceEntry, requestPermission, - saveBackend, - saveDirHandle, - saveFolderPath, -} from '../storage/handlePersistence'; -import {IndexedDbNoteStore} from '../storage/indexedDbStore'; -import {TauriNoteStore} from '../storage/tauriStore'; -import type {NoteStore} from '../storage/types'; + touchWorkspace, + workspaceIdForPath, +} from '../storage/workspaceRegistry'; import {TimeoutError, withTimeout} from '../timeout'; export type StorageState = - | 'loading' // checking for a previously-chosen backend - | 'choosing' // no backend chosen yet — show the first-run choice + | 'loading' // checking for a previously-chosen workspace + | 'choosing' // no workspace chosen yet — show the first-run choice | 'needs-permission' // a folder was remembered, but permission must be re-granted | 'ready'; @@ -32,18 +34,61 @@ const supportsFolders = isTauri || supportsFileSystem; const BROWSER_LABEL = isTauri ? 'In this app' : 'In this browser'; -/** Display label for a folder path: its last segment (e.g. `/Users/me/Notes` → `Notes`). */ -function folderName(path: string): string { - const parts = path.split(/[/\\]/).filter(Boolean); - return parts[parts.length - 1] ?? path; +/** The registry entry's display name (the in-browser workspace gets a context-aware label). */ +function displayName(entry: WorkspaceEntry): string { + return entry.backend === 'indexeddb' ? BROWSER_LABEL : entry.name; +} + +/** A workspace as exposed to the UI: the registry entry sans handle, with a display-ready name. */ +export interface WorkspaceInfo { + id: string; + backend: StorageBackend; + name: string; + /** The folder path (`tauri-fs` only), for disambiguating captions. */ + path?: string; +} + +function toInfo(entry: WorkspaceEntry): WorkspaceInfo { + return {id: entry.id, backend: entry.backend, name: displayName(entry), path: entry.path}; +} + +/** The registry entry for the in-browser/in-app workspace (created on first use). */ +function browserEntry(): WorkspaceEntry { + return { + id: 'indexeddb', + backend: 'indexeddb', + name: 'Browser storage', + lastOpenedAt: Date.now(), + }; } /** - * How long the startup folder probe may run before we give up and route back to the picker. The - * underlying walk can't be cancelled (a Tauri `invoke`), so it keeps running, but the UI moves on. + * How long the folder probe may run before we give up. The underlying walk can't be cancelled (a + * Tauri `invoke`), so it keeps running, but the UI moves on. */ const PROBE_TIMEOUT_MS = 10_000; +/** + * How an entry is being opened: `seq` is the supersession ticket; `isBootstrap` distinguishes the + * launch restore (errors land on the choice screen; no permission prompts — there's no user + * gesture yet) from a user-initiated switch (failures leave the current workspace mounted). + */ +interface OpenOpts { + seq: number; + isBootstrap?: boolean; +} + +/** The user-facing message for a failed folder probe. */ +function probeFailureMessage(err: unknown, timedOut: boolean): string { + if (timedOut) { + return ( + 'That folder took too long to open — it may be very large or on a disconnected ' + + 'drive. Choose a different folder.' + ); + } + return err instanceof Error ? err.message : 'Your notes folder is no longer available.'; +} + export interface NotesStorage { state: StorageState; /** The ready note store (file-system or in-browser), or null until `ready`. */ @@ -51,6 +96,10 @@ export interface NotesStorage { backend: StorageBackend | null; /** Human label for the active storage (folder name, or "In this browser"). */ storageLabel: string | null; + /** The active workspace's registry id, or null until `ready`. */ + activeWorkspaceId: string | null; + /** Known workspaces, most recently opened first (includes the active one). */ + workspaces: WorkspaceInfo[]; error: string | null; /** Running inside the desktop app (native folder access) rather than a plain browser. */ isTauri: boolean; @@ -64,106 +113,251 @@ export interface NotesStorage { useBrowserStorage(): Promise<void>; /** Re-request permission for the remembered folder (user gesture). */ grantPermission(): Promise<void>; - /** Forget the chosen backend and return to the choice screen. */ + /** Return to the choice screen (the workspace registry is kept). */ reset(): Promise<void>; + /** + * Switch this window to a known workspace. On desktop, a workspace already shown in another + * window is focused there instead. Resolves false when the workspace could not be opened (the + * current one stays mounted). + */ + openWorkspace(id: string): Promise<boolean>; + /** Desktop only: open a workspace in its own window (focused if already open somewhere). */ + openInNewWindow(id: string): Promise<void>; + /** Drop a workspace from the registry (its notes on disk are untouched). */ + removeWorkspace(id: string): Promise<void>; + /** Re-read the registry (e.g. before showing a recents list — other windows may have written). */ + refreshWorkspaces(): Promise<void>; } /** - * Folder picking, in-browser storage, and the permission lifecycle, as a backend-agnostic state - * machine that hands `Workspace` a ready {@link NoteStore}. The chosen backend is remembered in - * IndexedDB so reloads restore it without re-asking. + * The workspace lifecycle: bootstrap (restore this window's workspace, or the last active one), + * switching, opening in new desktop windows, and the FSA permission dance — as a backend-agnostic + * state machine that hands `Workspace` a ready {@link NoteStore}. Every opened workspace is + * remembered in the registry (IndexedDB), which feeds the recents UI. */ export function useNotesStorage(): NotesStorage { const [state, setState] = useState<StorageState>('loading'); const [store, setStore] = useState<NoteStore | null>(null); const [backend, setBackend] = useState<StorageBackend | null>(null); const [storageLabel, setStorageLabel] = useState<string | null>(null); + const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null); + const [workspaces, setWorkspaces] = useState<WorkspaceInfo[]>([]); const [error, setError] = useState<string | null>(null); - // Set once the user takes a storage action. The bootstrap effect bails if this is set, so a slow - // IndexedDB read can't clobber a state the user just chose. - const interactedRef = useRef(false); + // Full registry entries (with handles) backing the exposed WorkspaceInfo list. + const entriesRef = useRef<WorkspaceEntry[]>([]); + // The entry awaiting a permission re-grant while in `needs-permission`. + const pendingRef = useRef<WorkspaceEntry | null>(null); + // Mirrors activeWorkspaceId for callbacks that must not go stale. + const activeIdRef = useRef<string | null>(null); + // Supersession counter: every storage action (and the bootstrap) claims a sequence number, and + // async continuations bail once a newer action has claimed a higher one — so a slow bootstrap + // read can't clobber a choice the user just made, and rapid switches can't interleave. + const opSeqRef = useRef(0); + + const beginOp = useCallback(() => ++opSeqRef.current, []); + const isStale = useCallback((seq: number) => opSeqRef.current !== seq, []); + + const refreshWorkspaces = useCallback(async () => { + try { + const entries = await loadWorkspaces(); + entriesRef.current = entries; + setWorkspaces(entries.map(toInfo)); + } catch { + // Registry unavailable (e.g. private mode) — keep whatever list we had. + } + }, []); + + /** + * Post-activation bookkeeping, best-effort: recency, and the desktop shell's window state. + * Guarded by the activation's `seq`: a newer activation supersedes this one, and its detached + * writes (last-active pointer, `set_window_workspace`) must not land for a workspace we've + * already left — otherwise a rapid A→B switch could restore A next launch or register A for a + * window showing B. Re-checked before each awaited write since supersession can happen mid-way. + */ + const finalizeActivation = useCallback( + async (entry: WorkspaceEntry, seq: number) => { + if (isStale(seq)) return; + try { + await touchWorkspace(entry); + } catch { + // Recency is best-effort; the workspace itself is already open. + } + if (isStale(seq)) return; + await refreshWorkspaces(); + if (!isTauri || isStale(seq)) return; + try { + const {invoke} = await import('@tauri-apps/api/core'); + if (isStale(seq)) return; + await invoke('set_window_workspace', {wsId: entry.id}); + } catch { + // Without the registration this window just won't be focus-if-open targetable. + } + if (isStale(seq)) return; + try { + const {getCurrentWindow} = await import('@tauri-apps/api/window'); + if (isStale(seq)) return; + await getCurrentWindow().setTitle(`${displayName(entry)} — Gravity Notes`); + } catch { + // Title stays generic. + } + }, + [isStale, refreshWorkspaces], + ); + + const activate = useCallback( + (entry: WorkspaceEntry, noteStore: NoteStore, seq: number) => { + pendingRef.current = null; + activeIdRef.current = entry.id; + setStore(noteStore); + setBackend(entry.backend); + setStorageLabel(displayName(entry)); + setActiveWorkspaceId(entry.id); + setError(null); + setState('ready'); + void finalizeActivation(entry, seq); + }, + [finalizeActivation], + ); - // On load, restore the previously-chosen backend. + const openTauriEntry = useCallback( + async (entry: WorkspaceEntry, opts: OpenOpts): Promise<boolean> => { + const {seq, isBootstrap = false} = opts; + if (!entry.path) return false; + const tauriStore = new TauriNoteStore(entry.path); + // Probe that the folder still exists/reads before landing in the workspace: if it + // was moved/deleted/unmounted, every fs call there would fail. A failed probe on + // bootstrap surfaces the error and routes to the choice screen so the user can + // re-pick, rather than stranding them on a broken workspace. The probe is + // time-bounded: a huge/strange folder (home dir, a network mount) makes the + // recursive walk hang instead of throw, which would otherwise leave the app stuck + // on the loading spinner — where the choice buttons are disabled — forever. + try { + await withTimeout(tauriStore.list(), PROBE_TIMEOUT_MS, 'Folder probe'); + } catch (err) { + if (isStale(seq)) return false; + const timedOut = err instanceof TimeoutError; + // A bootstrap timeout means the folder is effectively unusable (too large / + // too slow), so forget the launch pointer — otherwise the next launch re-hangs + // on the same path. The registry entry itself is kept (removable in the UI). + if (timedOut && isBootstrap) await clearLastActive().catch(() => {}); + if (isStale(seq)) return false; + // Surface the error either way: on bootstrap it also routes to the choice screen; + // on a switch it stays on the current workspace but the message must still show + // (the FolderGate recents list reads `error` and has no toaster of its own, so + // without this a click on a gone folder would silently do nothing). + setError(probeFailureMessage(err, timedOut)); + if (isBootstrap) setState('choosing'); + return false; + } + if (isStale(seq)) return false; + activate(entry, tauriStore, seq); + return true; + }, + [activate, isStale], + ); + + const openFsaEntry = useCallback( + async (entry: WorkspaceEntry, opts: OpenOpts): Promise<boolean> => { + const {seq, isBootstrap = false} = opts; + if (!entry.handle) return false; + let permission: PermissionState; + try { + permission = await queryPermission(entry.handle); + } catch { + // A dead/detached handle can throw rather than report 'prompt'; treat it as + // not-granted so we fall through to the re-grant path instead of letting the + // rejection escape as an unhandled promise (the caller chain is unguarded). + permission = 'prompt'; + } + if (isStale(seq)) return false; + if (permission === 'granted') { + activate(entry, new FileSystemNoteStore(entry.handle), seq); + return true; + } + if (!isBootstrap) { + // A switch runs off a click/keypress, so try the permission prompt inline before + // falling back to the grant screen. + try { + if (await requestPermission(entry.handle)) { + if (isStale(seq)) return false; + activate(entry, new FileSystemNoteStore(entry.handle), seq); + return true; + } + } catch { + // Denied/expired activation — the grant screen below handles it. + } + if (isStale(seq)) return false; + } + pendingRef.current = entry; + // Set the label so the grant screen can name the folder it's asking about. + setStorageLabel(entry.name); + setState('needs-permission'); + return true; + }, + [activate, isStale], + ); + + /** + * Open a registry entry in this window. Returns true when handled (ready, or routed to the + * permission gate) and false when it failed — on a bootstrap that lands on the choice screen + * with an error; on a switch the current workspace stays mounted untouched. + */ + const openEntry = useCallback( + async (entry: WorkspaceEntry, opts: OpenOpts): Promise<boolean> => { + if (entry.backend === 'indexeddb') { + if (isStale(opts.seq)) return false; + activate(entry, new IndexedDbNoteStore(), opts.seq); + return true; + } + if (entry.backend === 'tauri-fs') return openTauriEntry(entry, opts); + return openFsaEntry(entry, opts); + }, + [activate, isStale, openTauriEntry, openFsaEntry], + ); + + // On load, restore this window's assigned workspace (desktop ws-windows), else the last active. useEffect(() => { let cancelled = false; + const seq = beginOp(); + const bail = () => cancelled || isStale(seq); (async () => { try { - const [chosen, savedHandle] = await Promise.all([loadBackend(), loadDirHandle()]); - if (cancelled || interactedRef.current) return; - // Back-compat: a stored handle with no backend flag is an old file-system user. - const kind = chosen ?? (savedHandle ? 'filesystem' : undefined); - if (kind === 'indexeddb') { - setStore(new IndexedDbNoteStore()); - setBackend('indexeddb'); - setStorageLabel(BROWSER_LABEL); - setState('ready'); - return; - } - if (kind === 'tauri-fs') { - // Native desktop folder: access is governed by the OS, not a per-session browser - // grant, so the remembered path opens straight to ready (no needs-permission). - const path = await loadFolderPath(); - if (cancelled || interactedRef.current) return; - if (!path) { - setState('choosing'); - return; - } - const tauriStore = new TauriNoteStore(path); - // Probe that the remembered folder still exists/reads before landing in the - // workspace: if it was moved/deleted/unmounted, every fs call there would fail. - // A failed probe surfaces the error and routes back to the choice screen so the - // user can re-pick, rather than stranding them on a broken workspace. The probe - // is time-bounded: a huge/strange folder (home dir, a network mount) makes the - // recursive walk hang instead of throw, which would otherwise leave the app stuck - // on the loading spinner — where the choice buttons are disabled — forever. + const entries = await loadWorkspaces(); + if (bail()) return; + entriesRef.current = entries; + setWorkspaces(entries.map(toInfo)); + // A desktop window created via "open in new window" was assigned its workspace + // before its page loaded; the main window has no assignment at launch. + let targetId: string | undefined; + if (isTauri) { try { - await withTimeout(tauriStore.list(), PROBE_TIMEOUT_MS, 'Folder probe'); - } catch (err) { - if (cancelled || interactedRef.current) return; - const timedOut = err instanceof TimeoutError; - // A timeout means the folder is effectively unusable (too large / too slow), - // so forget it — otherwise the next launch re-hangs on the same path. A plain - // read error may be transient (an unplugged drive that comes back), so keep - // the choice and only route back to the picker. - if (timedOut) await clearStorageChoice().catch(() => {}); - if (cancelled || interactedRef.current) return; - setError( - timedOut - ? 'That folder took too long to open — it may be very large or on a ' + - 'disconnected drive. Choose a different folder.' - : err instanceof Error - ? err.message - : 'Your notes folder is no longer available.', - ); - setState('choosing'); - return; + const {invoke} = await import('@tauri-apps/api/core'); + targetId = (await invoke<string | null>('window_workspace')) ?? undefined; + } catch { + targetId = undefined; } - if (cancelled || interactedRef.current) return; - setStore(tauriStore); - setBackend('tauri-fs'); - setStorageLabel(folderName(path)); - setState('ready'); - return; + if (bail()) return; } - if (kind === 'filesystem' && savedHandle) { - const permission = await queryPermission(savedHandle); - if (cancelled || interactedRef.current) return; - // Set the label only after the post-await guard, so a cancelled/interacted - // bootstrap never writes state the user just superseded. - setStorageLabel(savedHandle.name); - if (permission === 'granted') { - setStore(new FileSystemNoteStore(savedHandle)); - setBackend('filesystem'); - setState('ready'); - } else { - setState('needs-permission'); - } + if (!targetId) { + targetId = await loadLastActiveId(); + if (bail()) return; + } + const entry = targetId + ? entries.find((candidate) => candidate.id === targetId) + : undefined; + if (!entry) { + setState('choosing'); return; } - setState('choosing'); + const opened = await openEntry(entry, {seq, isBootstrap: true}); + if (bail()) return; + // A restored entry that couldn't be opened but set no state itself (e.g. a + // malformed entry missing its path/handle) must not strand the app on the + // 'loading' spinner — fall through to the choice screen. + if (!opened) setState('choosing'); } catch (err) { // IndexedDB blocked (e.g. private mode) — don't hang on the spinner. - if (cancelled || interactedRef.current) return; + if (bail()) return; setError(err instanceof Error ? err.message : 'Could not restore your storage.'); setState('choosing'); } @@ -171,10 +365,10 @@ export function useNotesStorage(): NotesStorage { return () => { cancelled = true; }; - }, []); + }, [beginOp, isStale, openEntry]); const pickFolder = useCallback(async () => { - interactedRef.current = true; + const seq = beginOp(); setError(null); if (isTauri) { try { @@ -186,12 +380,18 @@ export function useNotesStorage(): NotesStorage { title: 'Choose your notes folder', }); if (typeof selected !== 'string') return; // dismissed - await saveFolderPath(selected); - await saveBackend('tauri-fs'); - setStore(new TauriNoteStore(selected)); - setBackend('tauri-fs'); - setStorageLabel(folderName(selected)); - setState('ready'); + if (isStale(seq)) return; + activate( + { + id: workspaceIdForPath(selected), + backend: 'tauri-fs', + name: folderNameFromPath(selected), + lastOpenedAt: Date.now(), + path: selected, + }, + new TauriNoteStore(selected), + seq, + ); } catch (err) { setError(err instanceof Error ? err.message : 'Could not open the folder.'); } @@ -206,75 +406,148 @@ export function useNotesStorage(): NotesStorage { setError('Permission to access the folder was denied.'); return; } - await saveDirHandle(handle); - await saveBackend('filesystem'); - setStore(new FileSystemNoteStore(handle)); - setBackend('filesystem'); - setStorageLabel(handle.name); - setState('ready'); + if (isStale(seq)) return; + // Re-picking a known folder must not spawn a duplicate entry — handles aren't + // comparable by value, so ask the registry (freshly read; other windows may write). + const existing = await findFsaEntry(handle, await loadWorkspaces()); + if (isStale(seq)) return; + const entry: WorkspaceEntry = existing + ? {...existing, handle, name: handle.name} + : { + id: `fsa:${crypto.randomUUID()}`, + backend: 'filesystem', + name: handle.name, + lastOpenedAt: Date.now(), + handle, + }; + activate(entry, new FileSystemNoteStore(handle), seq); } catch (err) { // The user dismissing the picker throws AbortError — not an error to show. if (err instanceof DOMException && err.name === 'AbortError') return; setError(err instanceof Error ? err.message : 'Could not open the folder.'); } - }, []); + }, [activate, beginOp, isStale]); const useBrowserStorage = useCallback(async () => { - interactedRef.current = true; + const seq = beginOp(); setError(null); try { - await saveBackend('indexeddb'); - setStore(new IndexedDbNoteStore()); - setBackend('indexeddb'); - setStorageLabel(BROWSER_LABEL); - setState('ready'); + activate(browserEntry(), new IndexedDbNoteStore(), seq); } catch (err) { setError(err instanceof Error ? err.message : 'Could not set up in-browser storage.'); } - }, []); + }, [activate, beginOp]); const grantPermission = useCallback(async () => { - interactedRef.current = true; + const seq = beginOp(); setError(null); try { - const savedHandle = await loadDirHandle(); - if (!savedHandle) { + const entry = pendingRef.current; + if (!entry?.handle) { setState('choosing'); return; } - if (await requestPermission(savedHandle)) { - await saveBackend('filesystem'); - setStore(new FileSystemNoteStore(savedHandle)); - setBackend('filesystem'); - setStorageLabel(savedHandle.name); - setState('ready'); + if (await requestPermission(entry.handle)) { + if (isStale(seq)) return; + activate(entry, new FileSystemNoteStore(entry.handle), seq); } else { setError('Permission to access the folder was denied.'); } } catch (err) { - // Without this, a thrown requestPermission/loadDirHandle became a swallowed unhandled - // rejection, stranding the user on the permission gate. The user dismissing the prompt - // throws AbortError — not an error to show (mirrors pickFolder). + // Without this, a thrown requestPermission became a swallowed unhandled rejection, + // stranding the user on the permission gate. The user dismissing the prompt throws + // AbortError — not an error to show (mirrors pickFolder). if (err instanceof DOMException && err.name === 'AbortError') return; setError(err instanceof Error ? err.message : 'Could not grant access.'); } - }, []); + }, [activate, beginOp, isStale]); const reset = useCallback(async () => { - interactedRef.current = true; - await clearStorageChoice(); + beginOp(); + pendingRef.current = null; + activeIdRef.current = null; setStore(null); setBackend(null); setStorageLabel(null); + setActiveWorkspaceId(null); setError(null); setState('choosing'); + // "Choose different storage" must actually stick: forget the launch pointer so the next + // relaunch lands on the choice screen instead of re-restoring (and re-gating) the folder + // the user just stepped away from. The registry (recents) itself is kept. + await clearLastActive().catch(() => {}); + // The choice screen lists recents — make sure they're fresh. + await refreshWorkspaces(); + }, [beginOp, refreshWorkspaces]); + + const openWorkspace = useCallback( + async (id: string): Promise<boolean> => { + if (id === activeIdRef.current) return true; + const seq = beginOp(); + setError(null); + if (isTauri) { + // Another window already showing this workspace gets focused instead of a + // second view of the same files. + try { + const {invoke} = await import('@tauri-apps/api/core'); + if (await invoke<boolean>('focus_workspace_window', {wsId: id})) { + // Focusing another window is still "using" that workspace — bump its + // recency so the recents/switcher ordering reflects it (this window, + // which stays put, is the only place that can record the visit). + if (isStale(seq)) return true; + const focused = entriesRef.current.find((e) => e.id === id); + if (focused) { + await touchWorkspace(focused).catch(() => {}); + await refreshWorkspaces(); + } + return true; + } + } catch { + // The shell couldn't answer — in-place switching still works without it. + } + if (isStale(seq)) return false; + } + let entry = entriesRef.current.find((candidate) => candidate.id === id); + if (!entry) { + await refreshWorkspaces(); + if (isStale(seq)) return false; + entry = entriesRef.current.find((candidate) => candidate.id === id); + } + // The in-browser workspace is offered in the switcher even before it exists in the + // registry — first open creates it. + if (!entry && id === 'indexeddb') entry = browserEntry(); + if (!entry) return false; + return openEntry(entry, {seq}); + }, + [beginOp, isStale, openEntry, refreshWorkspaces], + ); + + const openInNewWindow = useCallback(async (id: string) => { + if (!isTauri) return; + const entry = entriesRef.current.find((candidate) => candidate.id === id); + if (!entry) return; + const {invoke} = await import('@tauri-apps/api/core'); + await invoke('open_workspace_window', { + wsId: id, + title: `${displayName(entry)} — Gravity Notes`, + }); }, []); + const removeWorkspace = useCallback( + async (id: string) => { + await removeWorkspaceEntry(id); + await refreshWorkspaces(); + }, + [refreshWorkspaces], + ); + return { state, store, backend, storageLabel, + activeWorkspaceId, + workspaces, error, isTauri, supportsFileSystem, @@ -283,5 +556,9 @@ export function useNotesStorage(): NotesStorage { useBrowserStorage, grantPermission, reset, + openWorkspace, + openInNewWindow, + removeWorkspace, + refreshWorkspaces, }; } diff --git a/src/hooks/useShortcuts.test.tsx b/src/hooks/useShortcuts.test.tsx index 199479f..5ad3f0b 100644 --- a/src/hooks/useShortcuts.test.tsx +++ b/src/hooks/useShortcuts.test.tsx @@ -22,6 +22,7 @@ function makeActions(): ShortcutActions { moveSelected: vi.fn(), duplicateSelected: vi.fn(), deleteSelected: vi.fn(), + openWorkspaces: vi.fn(), }; } @@ -44,6 +45,20 @@ describe('useShortcuts', () => { expect(actions.createNote).not.toHaveBeenCalled(); }); + it('does not fire global chords while a modal dialog is open', () => { + const actions = makeActions(); + renderHook(() => useShortcuts(actions)); + // A Gravity modal (role="dialog") is open — it owns the keyboard; ⌘N must not create a + // note behind it (nor ⌘⇧⌫ delete one, etc.). + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + document.body.appendChild(dialog); + press({key: 'n', ctrlKey: true}); + press({key: 'Backspace', ctrlKey: true, shiftKey: true}); + expect(actions.createNote).not.toHaveBeenCalled(); + expect(actions.deleteSelected).not.toHaveBeenCalled(); + }); + it('selects the previous note on ctrl+k', () => { const actions = makeActions(); renderHook(() => useShortcuts(actions)); @@ -275,4 +290,31 @@ describe('useShortcuts', () => { press({key: '\\', ctrlKey: true}); expect(actions.peekSidebar).not.toHaveBeenCalled(); }); + + it('opens the workspace switcher on ⌃R (the Control key specifically)', () => { + const actions = makeActions(); + renderHook(() => useShortcuts(actions)); + const event = press({key: 'r', code: 'KeyR', ctrlKey: true}); + expect(actions.openWorkspaces).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + }); + + it('does NOT open the switcher on ⌘R — a ctrl binding rejects the command key', () => { + const actions = makeActions(); + renderHook(() => useShortcuts(actions)); + press({key: 'r', code: 'KeyR', metaKey: true}); + // ⌘⌃R is a different chord too — the trigger means Control alone. + press({key: 'r', code: 'KeyR', metaKey: true, ctrlKey: true}); + expect(actions.openWorkspaces).not.toHaveBeenCalled(); + }); + + it('still opens the switcher on ⌃R while typing in an input (VS Code-style)', () => { + const actions = makeActions(); + renderHook(() => useShortcuts(actions)); + const input = document.createElement('input'); + document.body.appendChild(input); + input.focus(); + press({key: 'r', code: 'KeyR', ctrlKey: true}); + expect(actions.openWorkspaces).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/hooks/useShortcuts.ts b/src/hooks/useShortcuts.ts index fb3b751..d37338e 100644 --- a/src/hooks/useShortcuts.ts +++ b/src/hooks/useShortcuts.ts @@ -1,6 +1,6 @@ import {useEffect, useRef} from 'react'; -import {SHORTCUTS, type ShortcutAction} from '../shortcuts'; +import {type GlobalBinding, SHORTCUTS, type ShortcutAction} from '../shortcuts'; export type ShortcutActions = Record<ShortcutAction, () => void>; @@ -10,6 +10,27 @@ function isTypingTarget(el: EventTarget | null): boolean { return el.isContentEditable || el.tagName === 'INPUT' || el.tagName === 'TEXTAREA'; } +/** Whether a modifier ('mod'/'ctrl') binding matches this keydown. */ +function matchesChord(event: KeyboardEvent, binding: GlobalBinding): boolean { + // 'mod' accepts either command modifier; 'ctrl' is the Control key alone (a ⌘-chord must + // NOT satisfy it — ⌃R and ⌘R are distinct on macOS). + const modifierOk = + binding.trigger === 'mod' + ? event.metaKey || event.ctrlKey + : event.ctrlKey && !event.metaKey; + // Prefer a physical-key (event.code) match when the binding specifies one; otherwise + // compare event.key case-insensitively. + const keyMatches = binding.code + ? event.code === binding.code + : event.key.toLowerCase() === binding.key.toLowerCase(); + return ( + modifierOk && + (binding.shift ? event.shiftKey : !event.shiftKey) && + !event.altKey && + keyMatches + ); +} + /** * Global keyboard shortcuts, driven by the SHORTCUTS descriptor (the same source * the help dialog renders from). Command-modifier combos act regardless of focus @@ -28,25 +49,20 @@ export function useShortcuts(actions: ShortcutActions): void { // ⌘[/⌘]) preempt the editor and stopPropagation; everything else runs on bubble as before. const tryHandle = (event: KeyboardEvent, capturePhase: boolean): void => { if (event.repeat) return; // a held key shouldn't fire the action repeatedly - const mod = event.metaKey || event.ctrlKey; + // A modal dialog owns the keyboard while it's open — don't let global chords act on the + // workspace behind it (e.g. ⌘N creating a stray note behind the ⌃R switcher, or ⌘⇧⌫ + // deleting a note behind a picker). Gravity modals render role="dialog" and unmount when + // closed, so its presence means one is open. The dialog's own keys are handled by its + // own listeners, not this hook. + if (document.querySelector('[role="dialog"]')) return; const typing = isTypingTarget(document.activeElement); for (const {global: binding} of SHORTCUTS) { if (!binding) continue; if (Boolean(binding.capture) !== capturePhase) continue; - const allowInTyping = binding.inTyping ?? binding.trigger === 'mod'; + const allowInTyping = binding.inTyping ?? binding.trigger !== 'bare'; if (typing && !allowInTyping) continue; - if (binding.trigger === 'mod') { - // Prefer a physical-key (event.code) match when the binding specifies one; - // otherwise compare event.key case-insensitively. - const keyMatches = binding.code - ? event.code === binding.code - : event.key.toLowerCase() === binding.key.toLowerCase(); - if ( - mod && - (binding.shift ? event.shiftKey : !event.shiftKey) && - !event.altKey && - keyMatches - ) { + if (binding.trigger === 'mod' || binding.trigger === 'ctrl') { + if (matchesChord(event, binding)) { event.preventDefault(); if (capturePhase) event.stopPropagation(); // keep it from reaching the editor actionsRef.current[binding.action](); diff --git a/src/isTauri.ts b/src/isTauri.ts index c7c78c7..7121a8a 100644 --- a/src/isTauri.ts +++ b/src/isTauri.ts @@ -5,3 +5,32 @@ * every feature-detect agrees. The `typeof window` guard keeps it safe under Node (tests). */ export const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; + +/** + * The label of the app's primary window. THE single source of truth for the "main vs workspace + * window" distinction, which is load-bearing across three places that must agree: the Rust shell + * hides (not closes) this label on ⌘W (`src-tauri/src/lib.rs`, macOS convention), `useNotes`'s + * close handler only `preventDefault()`s for it (so ws-N windows flush-then-close), and the + * `core:window:allow-destroy` capability lets ws-N windows actually close. Keep the Rust literal + * `"main"` and this constant in sync. + */ +export const MAIN_WINDOW_LABEL = 'main'; + +/** + * The current Tauri window's label, synchronously — the same field `getCurrentWindow()` reads, + * without pulling `@tauri-apps/api` into the caller (or the web bundle). Workspace windows are + * `ws-1`, `ws-2`, …. Outside the shell (web build, tests) this returns {@link MAIN_WINDOW_LABEL}, + * so main-window-gated behavior stays on in the browser. + */ +export function currentWindowLabel(): string { + if (!isTauri) return MAIN_WINDOW_LABEL; + const internals = ( + window as {__TAURI_INTERNALS__?: {metadata?: {currentWindow?: {label?: string}}}} + ).__TAURI_INTERNALS__; + return internals?.metadata?.currentWindow?.label ?? MAIN_WINDOW_LABEL; +} + +/** Whether this is the app's primary window (see {@link MAIN_WINDOW_LABEL}); true on web/tests. */ +export function isMainWindow(): boolean { + return currentWindowLabel() === MAIN_WINDOW_LABEL; +} diff --git a/src/shortcuts.ts b/src/shortcuts.ts index 02c73e9..fb62bfa 100644 --- a/src/shortcuts.ts +++ b/src/shortcuts.ts @@ -16,12 +16,16 @@ export type ShortcutAction = | 'renameSelected' | 'moveSelected' | 'duplicateSelected' - | 'deleteSelected'; + | 'deleteSelected' + | 'openWorkspaces'; /** How a globally-handled shortcut maps to a key event. */ export interface GlobalBinding { - /** 'mod' = ⌘/Ctrl combo; 'bare' = the key alone. */ - trigger: 'mod' | 'bare'; + /** + * 'mod' = ⌘/Ctrl combo; 'ctrl' = the Control key specifically (⌘ absent — VS Code-style ⌃ + * chords, distinct from ⌘ on macOS); 'bare' = the key alone. + */ + trigger: 'mod' | 'ctrl' | 'bare'; /** `event.key` to match. For the 'mod' trigger the comparison is case-insensitive. */ key: string; /** @@ -32,9 +36,9 @@ export interface GlobalBinding { code?: string; /** Which action to fire. */ action: ShortcutAction; - /** For a 'mod' binding, also require Shift (default: Shift must be absent). */ + /** For a 'mod'/'ctrl' binding, also require Shift (default: Shift must be absent). */ shift?: boolean; - /** May fire while a typing surface (input/textarea/contenteditable) is focused. Default: mod→true, bare→false. */ + /** May fire while a typing surface (input/textarea/contenteditable) is focused. Default: mod/ctrl→true, bare→false. */ inTyping?: boolean; /** * Handle in the capture phase and `stopPropagation`, so the key never reaches the editor. Needed @@ -140,6 +144,15 @@ export const SHORTCUTS: ShortcutDescriptor[] = [ group: 'Navigation', global: {trigger: 'mod', key: "'", action: 'peekSidebar'}, }, + { + keys: 'ctrl+r', + description: 'Switch workspace (recent folders)', + group: 'Navigation', + // The Control key specifically — VS Code's "Open Recent" chord on macOS. ⌘R stays free + // (it's browser reload in the web build). On Windows/Linux web this shadows the reload + // shortcut, exactly as VS Code-web does. Match the physical key (layout-independent). + global: {trigger: 'ctrl', key: 'r', code: 'KeyR', action: 'openWorkspaces', inTyping: true}, + }, { keys: 'mod+shift+enter', description: 'New note', diff --git a/src/storage/handlePersistence.ts b/src/storage/handlePersistence.ts deleted file mode 100644 index 7ce0dc6..0000000 --- a/src/storage/handlePersistence.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Persistence + permission helpers for the chosen notes folder. - * - * `FileSystemDirectoryHandle` is structured-cloneable, so we can stash it in - * IndexedDB and recover the same folder on the next visit. Browsers still - * require a fresh permission grant per session, so on load we re-query and (on a - * user gesture) re-request permission. - */ - -const DB_NAME = 'gravity-notes'; -const STORE_NAME = 'handles'; -const HANDLE_KEY = 'notes-dir'; -const BACKEND_KEY = 'backend'; -const FOLDER_PATH_KEY = 'notes-folder-path'; - -/** - * Which storage backend the user chose: - * - `filesystem` — a folder picked via the browser File System Access API (Chromium web build); - * - `tauri-fs` — a folder on disk via native Rust commands (the desktop app, where the FSA API is - * unavailable in WKWebView); the chosen folder is remembered as a plain path string; - * - `indexeddb` — in-browser / in-app IndexedDB. - */ -export type StorageBackend = 'filesystem' | 'tauri-fs' | 'indexeddb'; - -function openDb(): Promise<IDBDatabase> { - return new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, 1); - req.onupgradeneeded = () => { - req.result.createObjectStore(STORE_NAME); - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} - -function tx<T>( - mode: IDBTransactionMode, - run: (store: IDBObjectStore) => IDBRequest<T>, -): Promise<T> { - return openDb().then( - (db) => - new Promise<T>((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, mode); - const request = run(transaction.objectStore(STORE_NAME)); - let result: T; - request.onsuccess = () => { - result = request.result; - }; - // Resolve once the transaction commits (so writes are durable), and always close - // the connection — on complete, error, or abort — so it can never leak or hang. - transaction.oncomplete = () => { - db.close(); - resolve(result); - }; - transaction.onerror = () => { - db.close(); - reject(transaction.error ?? request.error); - }; - transaction.onabort = () => { - db.close(); - reject( - transaction.error ?? - new DOMException('IndexedDB transaction aborted', 'AbortError'), - ); - }; - }), - ); -} - -export function saveDirHandle(handle: FileSystemDirectoryHandle): Promise<void> { - return tx('readwrite', (store) => store.put(handle, HANDLE_KEY)).then(() => undefined); -} - -export function loadDirHandle(): Promise<FileSystemDirectoryHandle | undefined> { - return tx<FileSystemDirectoryHandle | undefined>('readonly', (store) => store.get(HANDLE_KEY)); -} - -/** Remember which backend the user chose, so reloads restore it without re-asking. */ -export function saveBackend(kind: StorageBackend): Promise<void> { - return tx('readwrite', (store) => store.put(kind, BACKEND_KEY)).then(() => undefined); -} - -export function loadBackend(): Promise<StorageBackend | undefined> { - return tx<StorageBackend | undefined>('readonly', (store) => store.get(BACKEND_KEY)); -} - -/** - * Remember the desktop app's chosen folder as a plain path string. Unlike the web backend's - * structured-cloned `FileSystemDirectoryHandle`, the native (`tauri-fs`) backend just needs the - * path — and the OS, not the browser, governs access, so there's no per-session re-grant. - */ -export function saveFolderPath(path: string): Promise<void> { - return tx('readwrite', (store) => store.put(path, FOLDER_PATH_KEY)).then(() => undefined); -} - -export function loadFolderPath(): Promise<string | undefined> { - return tx<string | undefined>('readonly', (store) => store.get(FOLDER_PATH_KEY)); -} - -/** Forget the chosen backend and any stored folder handle/path (back to the first-run choice). */ -export function clearStorageChoice(): Promise<void> { - // All three deletes in one readwrite transaction: a single connection and one atomic commit - // (the earlier requests share the transaction; we return the last so `tx` resolves on commit). - return tx('readwrite', (store) => { - store.delete(HANDLE_KEY); - store.delete(FOLDER_PATH_KEY); - return store.delete(BACKEND_KEY); - }).then(() => undefined); -} - -/** Check the current permission state without prompting. */ -export async function queryPermission(handle: FileSystemDirectoryHandle): Promise<PermissionState> { - return handle.queryPermission({mode: 'readwrite'}); -} - -/** Request read-write permission. MUST be called from a user gesture. */ -export async function requestPermission(handle: FileSystemDirectoryHandle): Promise<boolean> { - if ((await handle.queryPermission({mode: 'readwrite'})) === 'granted') { - return true; - } - return (await handle.requestPermission({mode: 'readwrite'})) === 'granted'; -} diff --git a/src/storage/workspaceRegistry.test.ts b/src/storage/workspaceRegistry.test.ts new file mode 100644 index 0000000..0eea1ec --- /dev/null +++ b/src/storage/workspaceRegistry.test.ts @@ -0,0 +1,243 @@ +import {IDBFactory} from 'fake-indexeddb'; +import 'fake-indexeddb/auto'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import { + type WorkspaceEntry, + clearLastActive, + findFsaEntry, + folderNameFromPath, + loadLastActiveId, + loadWorkspaces, + removeWorkspace, + touchWorkspace, + workspaceIdForPath, +} from './workspaceRegistry'; + +/** Write the pre-workspace (v1) database shape: a bare `handles` store with the legacy keys. */ +function seedLegacyDb(entries: Record<string, unknown>): Promise<void> { + return new Promise((resolve, reject) => { + const req = indexedDB.open('gravity-notes', 1); + req.onupgradeneeded = () => { + req.result.createObjectStore('handles'); + }; + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction('handles', 'readwrite'); + const store = tx.objectStore('handles'); + for (const [key, value] of Object.entries(entries)) store.put(value, key); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }; + req.onerror = () => reject(req.error); + }); +} + +/** Raw read of a `handles` key at the CURRENT db version (v2), for post-migration assertions. */ +function readHandlesKey(key: string): Promise<unknown> { + return new Promise((resolve, reject) => { + const req = indexedDB.open('gravity-notes', 2); + req.onsuccess = () => { + const db = req.result; + const get = db.transaction('handles', 'readonly').objectStore('handles').get(key); + get.onsuccess = () => { + db.close(); + resolve(get.result); + }; + get.onerror = () => { + db.close(); + reject(get.error); + }; + }; + req.onerror = () => reject(req.error); + }); +} + +function entry(over: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: 'tauri:/Users/me/Notes', + backend: 'tauri-fs', + name: 'Notes', + lastOpenedAt: 0, + path: '/Users/me/Notes', + ...over, + }; +} + +beforeEach(() => { + // Fresh in-memory IndexedDB per test so registries don't leak between cases. + vi.stubGlobal('indexedDB', new IDBFactory()); + // Deterministic, strictly-increasing timestamps so recency ordering is stable. + let clock = 1000; + vi.spyOn(Date, 'now').mockImplementation(() => ++clock); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('workspaceRegistry — migration from the v1 single choice', () => { + it('seeds a tauri-fs entry from the legacy backend + path and points last-active at it', async () => { + await seedLegacyDb({backend: 'tauri-fs', 'notes-folder-path': '/Users/me/Notes'}); + + const workspaces = await loadWorkspaces(); + + expect(workspaces).toHaveLength(1); + expect(workspaces[0]).toMatchObject({ + id: 'tauri:/Users/me/Notes', + backend: 'tauri-fs', + name: 'Notes', + path: '/Users/me/Notes', + }); + expect(await loadLastActiveId()).toBe('tauri:/Users/me/Notes'); + // The legacy keys are left in place (harmless), just never maintained again. + expect(await readHandlesKey('backend')).toBe('tauri-fs'); + }); + + it('seeds a filesystem entry (deterministic fsa:legacy id) from a stored handle', async () => { + // A real FSA handle can't exist under Node; any structured-cloneable object stands in. + const handle = {name: 'notes'}; + await seedLegacyDb({backend: 'filesystem', 'notes-dir': handle}); + + const workspaces = await loadWorkspaces(); + + expect(workspaces).toHaveLength(1); + expect(workspaces[0]).toMatchObject({ + id: 'fsa:legacy', + backend: 'filesystem', + name: 'notes', + }); + expect(workspaces[0].handle).toEqual(handle); + expect(await loadLastActiveId()).toBe('fsa:legacy'); + }); + + it('treats a stored handle with no backend flag as a filesystem user (v1 back-compat)', async () => { + await seedLegacyDb({'notes-dir': {name: 'old-notes'}}); + + const workspaces = await loadWorkspaces(); + + expect(workspaces).toHaveLength(1); + expect(workspaces[0]).toMatchObject({id: 'fsa:legacy', backend: 'filesystem'}); + }); + + it('seeds the in-browser entry from an indexeddb choice', async () => { + await seedLegacyDb({backend: 'indexeddb'}); + + const workspaces = await loadWorkspaces(); + + expect(workspaces).toHaveLength(1); + expect(workspaces[0]).toMatchObject({id: 'indexeddb', backend: 'indexeddb'}); + expect(await loadLastActiveId()).toBe('indexeddb'); + }); + + it('yields an empty registry when there is nothing to migrate', async () => { + expect(await loadWorkspaces()).toEqual([]); + expect(await loadLastActiveId()).toBeUndefined(); + }); + + it('is idempotent under a concurrent double-run (StrictMode / two windows)', async () => { + await seedLegacyDb({backend: 'tauri-fs', 'notes-folder-path': '/Users/me/Notes'}); + + const [first, second] = await Promise.all([loadWorkspaces(), loadWorkspaces()]); + + expect(first).toHaveLength(1); + expect(second).toHaveLength(1); + // The deterministic id makes the double-seed an overwrite, never a duplicate. + expect(await loadWorkspaces()).toHaveLength(1); + }); + + it('does not re-seed once real entries exist (legacy keys still present)', async () => { + await seedLegacyDb({backend: 'tauri-fs', 'notes-folder-path': '/Users/me/Notes'}); + await loadWorkspaces(); // migrate + await touchWorkspace(entry({id: 'tauri:/Other', path: '/Other', name: 'Other'})); + await removeWorkspace('tauri:/Users/me/Notes'); + + const workspaces = await loadWorkspaces(); + + // The removed migrated entry must NOT resurrect from the (still present) legacy keys. + expect(workspaces.map((w) => w.id)).toEqual(['tauri:/Other']); + }); +}); + +describe('workspaceRegistry — recency and removal', () => { + it('touchWorkspace upserts, bumps lastOpenedAt, and orders most-recent-first', async () => { + await touchWorkspace(entry({id: 'tauri:/A', path: '/A', name: 'A'})); + await touchWorkspace(entry({id: 'tauri:/B', path: '/B', name: 'B'})); + + expect((await loadWorkspaces()).map((w) => w.id)).toEqual(['tauri:/B', 'tauri:/A']); + expect(await loadLastActiveId()).toBe('tauri:/B'); + + // Re-touching A moves it back to the front — an upsert, not a duplicate. + await touchWorkspace(entry({id: 'tauri:/A', path: '/A', name: 'A'})); + const workspaces = await loadWorkspaces(); + expect(workspaces.map((w) => w.id)).toEqual(['tauri:/A', 'tauri:/B']); + expect(await loadLastActiveId()).toBe('tauri:/A'); + }); + + it('removeWorkspace drops the entry and clears last-active only when it pointed there', async () => { + await touchWorkspace(entry({id: 'tauri:/A', path: '/A', name: 'A'})); + await touchWorkspace(entry({id: 'tauri:/B', path: '/B', name: 'B'})); + + // B is last-active; removing A keeps the pointer. + await removeWorkspace('tauri:/A'); + expect((await loadWorkspaces()).map((w) => w.id)).toEqual(['tauri:/B']); + expect(await loadLastActiveId()).toBe('tauri:/B'); + + // Removing B (the pointer target) clears it. + await removeWorkspace('tauri:/B'); + expect(await loadWorkspaces()).toEqual([]); + expect(await loadLastActiveId()).toBeUndefined(); + }); + + it('clearLastActive forgets the launch pointer but keeps the registry', async () => { + await touchWorkspace(entry()); + + await clearLastActive(); + + expect(await loadLastActiveId()).toBeUndefined(); + expect(await loadWorkspaces()).toHaveLength(1); + }); +}); + +describe('workspaceRegistry — helpers', () => { + it('workspaceIdForPath strips trailing separators so re-picks dedupe', () => { + expect(workspaceIdForPath('/Users/me/Notes')).toBe('tauri:/Users/me/Notes'); + expect(workspaceIdForPath('/Users/me/Notes/')).toBe('tauri:/Users/me/Notes'); + expect(workspaceIdForPath('/Users/me/Notes//')).toBe('tauri:/Users/me/Notes'); + }); + + it('folderNameFromPath returns the leaf segment', () => { + expect(folderNameFromPath('/Users/me/Notes')).toBe('Notes'); + expect(folderNameFromPath('/Users/me/Notes/')).toBe('Notes'); + expect(folderNameFromPath('C:\\Notes\\Vault')).toBe('Vault'); + }); + + it('findFsaEntry matches via isSameEntry and survives a comparison that throws', async () => { + // Stored handles are opaque objects; the PROBE's isSameEntry is asked about each one. + const broken = {name: 'broken'} as unknown as FileSystemDirectoryHandle; + const other = {name: 'other'} as unknown as FileSystemDirectoryHandle; + const target = {name: 'target'} as unknown as FileSystemDirectoryHandle; + const entries: WorkspaceEntry[] = [ + entry({id: 'fsa:1', backend: 'filesystem', path: undefined, handle: broken}), + entry({id: 'fsa:2', backend: 'filesystem', path: undefined, handle: other}), + entry({id: 'fsa:3', backend: 'filesystem', path: undefined, handle: target}), + entry({id: 'tauri:/A'}), // non-FSA entries are ignored + ]; + const probe = { + isSameEntry: async (candidate: unknown) => { + if (candidate === broken) throw new Error('dead handle'); // caught → not a match + return candidate === target; + }, + } as unknown as FileSystemDirectoryHandle; + + const found = await findFsaEntry(probe, entries); + + expect(found?.id).toBe('fsa:3'); + }); +}); diff --git a/src/storage/workspaceRegistry.ts b/src/storage/workspaceRegistry.ts new file mode 100644 index 0000000..b3b182b --- /dev/null +++ b/src/storage/workspaceRegistry.ts @@ -0,0 +1,283 @@ +/** + * The workspace registry: every folder (or in-browser store) the user has opened, with recency, + * plus permission helpers for the web (FSA) backend. + * + * `FileSystemDirectoryHandle` is structured-cloneable, so web folder workspaces stash their handle + * in IndexedDB and recover the same folder on the next visit (browsers still require a fresh + * permission grant per session). Desktop (`tauri-fs`) workspaces are plain path strings. + * + * ⚠️ This is deliberately the ONLY module that opens the `gravity-notes` IndexedDB database. It + * opens at version 2; anything else opening the same name at a lower version would throw + * `VersionError` after the first upgrade. Connections are opened per operation and always closed + * (see `tx`), which keeps the window for a cross-window upgrade block small — but not zero, so + * `openDb` still handles `blocked` (reject, don't hang) and `versionchange` (step aside). + * + * v1 (the single-choice era) kept exactly one backend in the `handles` store; v2 adds the + * `workspaces` store. The legacy keys are migrated into a deterministic workspace entry on first + * read and then left untouched (never maintained again). + */ + +const DB_NAME = 'gravity-notes'; +const DB_VERSION = 2; +const HANDLES_STORE = 'handles'; +const WORKSPACES_STORE = 'workspaces'; + +// Legacy single-choice keys (v1), read once by the migration and never written again. +const LEGACY_HANDLE_KEY = 'notes-dir'; +const LEGACY_BACKEND_KEY = 'backend'; +const LEGACY_FOLDER_PATH_KEY = 'notes-folder-path'; + +/** Which workspace the main window restores on launch (the most recently opened anywhere). */ +const LAST_ACTIVE_KEY = 'last-active-workspace'; + +/** + * Which storage backend a workspace uses: + * - `filesystem` — a folder picked via the browser File System Access API (Chromium web build); + * - `tauri-fs` — a folder on disk via native Rust commands (the desktop app, where the FSA API is + * unavailable in WKWebView); the folder is remembered as a plain path string; + * - `indexeddb` — in-browser / in-app IndexedDB (a single special workspace). + */ +export type StorageBackend = 'filesystem' | 'tauri-fs' | 'indexeddb'; + +export interface WorkspaceEntry { + /** `'indexeddb'` (singleton), `tauri:<path>`, or `fsa:<uuid>` (`fsa:legacy` when migrated). */ + id: string; + backend: StorageBackend; + /** Display name: the folder's leaf name (UI supplies its own label for `indexeddb`). */ + name: string; + lastOpenedAt: number; + /** The folder path (`tauri-fs` only). */ + path?: string; + /** The folder handle (`filesystem` only; structured-clones into IndexedDB). */ + handle?: FileSystemDirectoryHandle; +} + +function openDb(): Promise<IDBDatabase> { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + // v0 (fresh install) creates both; v1 → v2 only adds the workspaces store. + if (!db.objectStoreNames.contains(HANDLES_STORE)) { + db.createObjectStore(HANDLES_STORE); + } + if (!db.objectStoreNames.contains(WORKSPACES_STORE)) { + db.createObjectStore(WORKSPACES_STORE, {keyPath: 'id'}); + } + }; + req.onsuccess = () => { + const db = req.result; + // Step aside for another window's version upgrade (v2 → future v3): close this + // short-lived connection so its `versionchange` transaction can proceed rather than + // blocking it. Connections are per-op and closed by `tx` anyway; this covers the rare + // window where an upgrade lands mid-connection. + db.onversionchange = () => db.close(); + resolve(db); + }; + req.onerror = () => reject(req.error); + // An upgrade blocked by another window/tab's open connection fires `blocked` and would + // otherwise leave this promise unsettled forever (hanging every tx → the loading spinner). + // Reject so the caller surfaces it, mirroring `indexedDbStore.ts`. + req.onblocked = () => + reject( + new DOMException('IndexedDB open blocked by another connection', 'BlockedError'), + ); + }); +} + +function tx<T>( + storeNames: string[], + mode: IDBTransactionMode, + run: (transaction: IDBTransaction) => IDBRequest<T>, +): Promise<T> { + return openDb().then( + (db) => + new Promise<T>((resolve, reject) => { + const transaction = db.transaction(storeNames, mode); + const request = run(transaction); + let result: T; + request.onsuccess = () => { + result = request.result; + }; + // Resolve once the transaction commits (so writes are durable), and always close + // the connection — on complete, error, or abort — so it can never leak or hang. + transaction.oncomplete = () => { + db.close(); + resolve(result); + }; + transaction.onerror = () => { + db.close(); + reject(transaction.error ?? request.error); + }; + transaction.onabort = () => { + db.close(); + reject( + transaction.error ?? + new DOMException('IndexedDB transaction aborted', 'AbortError'), + ); + }; + }), + ); +} + +function byRecency(entries: WorkspaceEntry[]): WorkspaceEntry[] { + return [...entries].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); +} + +/** A folder path's stable workspace id (trailing separators stripped so re-picks dedupe). */ +export function workspaceIdForPath(path: string): string { + const normalized = path.replace(/[/\\]+$/, '') || path; + return `tauri:${normalized}`; +} + +/** Display label for a folder path: its last segment (e.g. `/Users/me/Notes` → `Notes`). */ +export function folderNameFromPath(path: string): string { + const parts = path.split(/[/\\]/).filter(Boolean); + return parts[parts.length - 1] ?? path; +} + +/** + * Find the registry entry for an already-picked FSA folder, if any — handles aren't comparable by + * value, so each stored handle is asked whether it points at the same directory. + */ +export async function findFsaEntry( + handle: FileSystemDirectoryHandle, + entries: WorkspaceEntry[], +): Promise<WorkspaceEntry | undefined> { + for (const entry of entries) { + if (entry.backend !== 'filesystem' || !entry.handle) continue; + try { + if (await handle.isSameEntry(entry.handle)) return entry; + } catch { + // A dead/foreign handle that can't be compared just isn't a match. + } + } + return undefined; +} + +/** + * Build the v2 entry for a v1 single-choice, or undefined when there's nothing to migrate. + * Deterministic ids (`indexeddb`, `tauri:<path>`, `fsa:legacy`) make a double-run (StrictMode's + * mount→unmount→mount, or two windows racing) an idempotent overwrite instead of a duplicate. + */ +function legacyEntry( + backend: StorageBackend | undefined, + handle: FileSystemDirectoryHandle | undefined, + path: string | undefined, +): WorkspaceEntry | undefined { + // Back-compat within back-compat: a stored handle with no backend flag is an old + // file-system user (mirrors the original bootstrap rule). + const kind = backend ?? (handle ? 'filesystem' : undefined); + if (kind === 'indexeddb') { + return { + id: 'indexeddb', + backend: 'indexeddb', + name: 'Browser storage', + lastOpenedAt: Date.now(), + }; + } + if (kind === 'tauri-fs' && path) { + return { + id: workspaceIdForPath(path), + backend: 'tauri-fs', + name: folderNameFromPath(path), + lastOpenedAt: Date.now(), + path, + }; + } + if (kind === 'filesystem' && handle) { + return { + id: 'fsa:legacy', + backend: 'filesystem', + name: handle.name, + lastOpenedAt: Date.now(), + handle, + }; + } + return undefined; +} + +/** + * All known workspaces, most recently opened first. On the first read after the v2 upgrade, + * migrates the v1 single choice into the registry (and points last-active at it). + */ +export async function loadWorkspaces(): Promise<WorkspaceEntry[]> { + const entries = await tx<WorkspaceEntry[]>([WORKSPACES_STORE], 'readonly', (t) => + t.objectStore(WORKSPACES_STORE).getAll(), + ); + if (entries.length > 0) return byRecency(entries); + + // Empty registry: seed it from the legacy keys, if any (left in place, never maintained). + const [backend, handle, path] = await Promise.all([ + tx<StorageBackend | undefined>([HANDLES_STORE], 'readonly', (t) => + t.objectStore(HANDLES_STORE).get(LEGACY_BACKEND_KEY), + ), + tx<FileSystemDirectoryHandle | undefined>([HANDLES_STORE], 'readonly', (t) => + t.objectStore(HANDLES_STORE).get(LEGACY_HANDLE_KEY), + ), + tx<string | undefined>([HANDLES_STORE], 'readonly', (t) => + t.objectStore(HANDLES_STORE).get(LEGACY_FOLDER_PATH_KEY), + ), + ]); + const seed = legacyEntry(backend, handle, path); + if (!seed) return []; + // One atomic commit for the entry + the last-active pointer. + await tx([WORKSPACES_STORE, HANDLES_STORE], 'readwrite', (t) => { + t.objectStore(WORKSPACES_STORE).put(seed); + return t.objectStore(HANDLES_STORE).put(seed.id, LAST_ACTIVE_KEY); + }); + return [seed]; +} + +/** Upsert a workspace with a fresh `lastOpenedAt` and point last-active at it (one atomic commit). */ +export function touchWorkspace(entry: WorkspaceEntry): Promise<void> { + return tx([WORKSPACES_STORE, HANDLES_STORE], 'readwrite', (t) => { + t.objectStore(WORKSPACES_STORE).put({...entry, lastOpenedAt: Date.now()}); + return t.objectStore(HANDLES_STORE).put(entry.id, LAST_ACTIVE_KEY); + }).then(() => undefined); +} + +/** Forget a workspace (its notes are untouched); clears last-active if it pointed there. */ +export function removeWorkspace(id: string): Promise<void> { + return tx([WORKSPACES_STORE, HANDLES_STORE], 'readwrite', (t) => { + t.objectStore(WORKSPACES_STORE).delete(id); + const handles = t.objectStore(HANDLES_STORE); + const pointer = handles.get(LAST_ACTIVE_KEY); + // addEventListener, NOT .onsuccess — `tx` assigns .onsuccess on the returned request to + // collect its result, which would silently replace a property handler set here. + pointer.addEventListener('success', () => { + if (pointer.result === id) handles.delete(LAST_ACTIVE_KEY); + }); + return pointer; + }).then(() => undefined); +} + +export function loadLastActiveId(): Promise<string | undefined> { + return tx<string | undefined>([HANDLES_STORE], 'readonly', (t) => + t.objectStore(HANDLES_STORE).get(LAST_ACTIVE_KEY), + ); +} + +/** + * Forget which workspace to restore on launch (the registry itself is kept). Used when the + * remembered folder is effectively unusable (e.g. the startup probe timed out), so the next launch + * lands on the choice screen instead of re-hanging on the same path. + */ +export function clearLastActive(): Promise<void> { + return tx([HANDLES_STORE], 'readwrite', (t) => + t.objectStore(HANDLES_STORE).delete(LAST_ACTIVE_KEY), + ).then(() => undefined); +} + +/** Check the current permission state without prompting. */ +export async function queryPermission(handle: FileSystemDirectoryHandle): Promise<PermissionState> { + return handle.queryPermission({mode: 'readwrite'}); +} + +/** Request read-write permission. MUST be called from a user gesture. */ +export async function requestPermission(handle: FileSystemDirectoryHandle): Promise<boolean> { + if ((await handle.queryPermission({mode: 'readwrite'})) === 'granted') { + return true; + } + return (await handle.requestPermission({mode: 'readwrite'})) === 'granted'; +} diff --git a/vite.config.ts b/vite.config.ts index 0e6a446..894734d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,6 +26,11 @@ export default defineConfig(({mode}) => ({ name: 'dom', environment: 'jsdom', include: ['src/**/*.test.tsx'], + // Rendering real Gravity components in jsdom (esp. the icon-picker popup, which + // mounts the full emoji/icon catalog) runs 2-5s per test locally and ~2.5x that + // under CI's loaded runner — past Vitest's 5s default. Give the whole DOM suite + // headroom so boundary-slow tests don't flake on CI. + testTimeout: 15_000, setupFiles: ['./src/test/setup.ts'], server: { deps: {