From 52de2b1ba2066ef7825e27287b34685b4f7f6a1e Mon Sep 17 00:00:00 2001 From: Gadzhi Gadzhiev <168296+resure@users.noreply.github.com> Date: Sun, 5 Jul 2026 13:30:40 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20per-note=20windows=20=E2=80=94=20op?= =?UTF-8?q?en=20any=20note=20in=20its=20own=20window=20(Apple=20Notes-styl?= =?UTF-8?q?e)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop only. Entry points: row ⋯/context menu "Open in New Window", ⌘↵ on a focused list row, and ⌘-click on a row. The note-N window opens smaller with both side panels closed, restores its ASSIGNED note (never touching the shared sidecar's last-active pointer), lands focus in the editor body, and titles itself after the note (rename included). Opening the same note again focuses its existing window (per-note focus-if-open, kept current via set_window_note); workspace-level focus-if-open skips note windows. ⌘0 / Window ▸ Main Window (native accelerator) surfaces the full workspace view for the FOCUSED window's workspace — focusing the existing main/ws-N window or creating one — never dragging forward a main window parked on another workspace. Also, since main + a note window now routinely show one folder: the note list re-reads on window focus (throttled); right-click / ⌘-click on a row no longer move selection or steal focus; and new windows open flash-free (no welcome-card flash during bootstrap — delayed spinner instead; shell-assigned windows skip the folder probe; the empty-editor placeholder waits for the initial load). Co-Authored-By: Claude Fable 5 --- README.md | 16 ++ src-tauri/capabilities/default.json | 2 +- src-tauri/src/lib.rs | 222 ++++++++++++++++++++++-- src/App.tsx | 10 ++ src/components/FolderGate.test.tsx | 26 ++- src/components/FolderGate.tsx | 55 ++++-- src/components/NoteList.test.tsx | 80 ++++++++- src/components/NoteList.tsx | 93 ++++++++-- src/components/ShortcutsDialog.test.tsx | 12 +- src/components/ShortcutsDialog.tsx | 7 +- src/components/Workspace.test.tsx | 2 + src/components/Workspace.tsx | 148 +++++++++++++--- src/hooks/useNotes.test.tsx | 46 +++++ src/hooks/useNotes.ts | 57 +++++- src/hooks/useNotesStorage.ts | 130 ++++++++++---- src/hooks/useShortcuts.ts | 4 +- src/isTauri.ts | 14 ++ src/shortcuts.ts | 21 +++ 18 files changed, 825 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index c37c5b4..72302c9 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,22 @@ Key modules: ### Backlog +Bugs (testing notes, 2026-07-05): + +- iCloud Drive: opening a folder whose files aren't downloaded yet hangs the app (probe/walk + blocks on download-on-demand materialization; needs async download handling or a clear error) +- Plain URLs in note text aren't highlighted as links — not even in preview mode +- Opening a note that begins with a `[[wiki link]]` sometimes pops the link tooltip immediately, + anchored top-left and stuck (likely a caret-lands-inside-link position race; sometimes fine) +- Markup (CodeMirror) mode: the undo/redo stack appears shared across notes — undo after + switching can revert the previous note's edits (WYSIWYG resets history on swap; CM may not) + +UI wishes: + +- Indicate that read-only preview mode is active (some visible state, not just the frozen editor) +- Indicate the selected folder scope when the folder rail is closed (the list silently stays + filtered) + - Search index update on external file updates (+ reload workspace menu item?) - Density (line spacing) setting to complement the per-note font/width overrides? - Restore all workspace windows on relaunch (today only the last-active one comes back) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d2f982f..6b81fff 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": ["main", "ws-*"], + "windows": ["main", "ws-*", "note-*"], "permissions": [ "core:default", "core:window:allow-start-dragging", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42ba9e3..168ad3d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -696,17 +696,22 @@ 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. +/// (`set_window_workspace` on every workspace open) and by `open_workspace_window` / +/// `open_note_window` (which assign the workspace *before* the window's page loads); drained by +/// the `Destroyed` window event. +/// - `notes`: a note window's label → the note id (store rel-path) it shows. Assigned by +/// `open_note_window` before the page loads, kept current by the frontend's `set_window_note` +/// (in-window navigation / rename / close), drained with `labels`. Powers per-note focus-if-open. +/// - `pending`: workspace ids (or `ws\u{1f}note` composite keys, see `note_pending_key`) 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 for the same target 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. +/// Powers focus-if-open, so the same workspace (or note) never casually ends up in two windows. #[derive(Default)] struct WindowState { labels: HashMap, + notes: HashMap, pending: HashSet, } @@ -717,15 +722,35 @@ struct WindowWorkspaces(Mutex); /// nothing restores workspace windows across launches. static NEXT_WS_WINDOW: AtomicU32 = AtomicU32::new(1); +/// Monotonic suffix for `note-N` window labels (same one-run lifetime story as `NEXT_WS_WINDOW`). +static NEXT_NOTE_WINDOW: AtomicU32 = AtomicU32::new(1); + +/// Label prefix of single-note windows. Mirrors `NOTE_WINDOW_PREFIX` in `src/isTauri.ts` — keep +/// them in sync. A note window carries a workspace assignment in `labels` like any window (its +/// page bootstraps through the same `window_workspace` ask), but workspace-level focus-if-open +/// skips it: asking for a workspace should land on a full workspace view, never on a lone note. +const NOTE_WINDOW_PREFIX: &str = "note-"; + +/// `pending` key for a note-window build. The unit separator can't occur in a workspace id or a +/// note rel-path, so composite keys share the set with plain workspace ids without colliding. +fn note_pending_key(ws_id: &str, note_id: &str) -> String { + format!("{ws_id}\u{1f}{note_id}") +} + /// The label of a window showing `ws_id`, excluding `exclude` (a window never "finds itself" -/// when asking where else its target workspace is open). +/// when asking where else its target workspace is open). Note windows never match — see +/// `NOTE_WINDOW_PREFIX`. fn window_label_for_workspace( map: &HashMap, ws_id: &str, exclude: Option<&str>, ) -> Option { map.iter() - .find(|(label, ws)| ws.as_str() == ws_id && Some(label.as_str()) != exclude) + .find(|(label, ws)| { + !label.starts_with(NOTE_WINDOW_PREFIX) + && ws.as_str() == ws_id + && Some(label.as_str()) != exclude + }) .map(|(label, _)| label.clone()) } @@ -848,6 +873,128 @@ async fn open_workspace_window( Ok(false) } +/// This window's assigned note id, if it's a note window (assigned before its page loads, so the +/// bootstrap ask can't race it — mirrors `window_workspace`). +#[tauri::command] +fn window_note( + window: tauri::WebviewWindow, + state: tauri::State<'_, WindowWorkspaces>, +) -> Option { + state.0.lock().unwrap().notes.get(window.label()).cloned() +} + +/// Record which note this (note) window is showing — the frontend calls it whenever the open note +/// changes (navigation, rename, close), keeping per-note focus-if-open accurate. `None` drops the +/// assignment (the window closed its note). +#[tauri::command] +fn set_window_note( + window: tauri::WebviewWindow, + state: tauri::State<'_, WindowWorkspaces>, + note_id: Option, +) { + let mut st = state.0.lock().unwrap(); + match note_id { + Some(id) => { + st.notes.insert(window.label().to_string(), id); + } + None => { + st.notes.remove(window.label()); + } + } +} + +/// Open a single note in its own window: focus the note window already showing this exact +/// (workspace, note), or create a fresh `note-N` window assigned to both before its page loads. +/// The frontend recognizes the `note-` label and opens with both side panels closed. Returns +/// whether an existing window was focused rather than a new one created. +/// +/// Async for the same reason as `open_workspace_window`: `WebviewWindowBuilder::build` may +/// deadlock on the main thread, and async commands run off it. +#[tauri::command] +async fn open_note_window( + app: tauri::AppHandle, + state: tauri::State<'_, WindowWorkspaces>, + ws_id: String, + note_id: String, + title: String, +) -> Result { + let pending_key = note_pending_key(&ws_id, ¬e_id); + let label = { + let mut st = state.0.lock().unwrap(); + // This exact note's window is already being built — don't spawn a second (mirrors the + // workspace path's pending guard). + if st.pending.contains(&pending_key) { + return Ok(true); + } + // Focus the live window already showing this (workspace, note); prune dead labels (a crash + // that skipped the Destroyed cleanup) along the way, like the workspace path. + loop { + let found = { + let WindowState { labels, notes, .. } = &mut *st; + notes + .iter() + .find(|(label, note)| { + note.as_str() == note_id + && labels.get(label.as_str()).map(String::as_str) + == Some(ws_id.as_str()) + }) + .map(|(label, _)| label.clone()) + }; + let Some(found) = found else { break }; + if let Some(target) = app.get_webview_window(&found) { + drop(st); + let _ = target.show(); + let _ = target.set_focus(); + return Ok(true); + } + st.labels.remove(&found); + st.notes.remove(&found); + } + let label = format!( + "{NOTE_WINDOW_PREFIX}{}", + NEXT_NOTE_WINDOW.fetch_add(1, Ordering::SeqCst) + ); + // Assign workspace AND note before the window exists (the page's first asks can't race), + // and mark the build pending — same discipline as `open_workspace_window`. + st.labels.insert(label.clone(), ws_id.clone()); + st.notes.insert(label.clone(), note_id.clone()); + st.pending.insert(pending_key.clone()); + label + }; + // Clone the main window's config like workspace windows do, sized down: this window shows a + // single note (both side panels closed), not a whole three-pane workspace. + let mut config = app.config().app.windows[0].clone(); + config.label = label.clone(); + config.title = title; + config.width = 760.0; + config.height = 640.0; + let built = tauri::WebviewWindowBuilder::from_config(&app, &config) + .and_then(|builder| builder.build()); + { + let mut st = state.0.lock().unwrap(); + st.pending.remove(&pending_key); + if built.is_err() { + st.labels.remove(&label); + st.notes.remove(&label); + } + } + let window = built.map_err(stringify)?; + apply_macos_chrome(&window); + Ok(false) +} + +/// Re-show + focus the main window: the Dock-icon Reopen and the no-window-focused fallback of +/// Window ▸ Main Window (⌘0) land here. On macOS ⌘W *hides* main rather than closing it (see the +/// close-request handler), so this is a show+focus; unminimize first so a minimized main actually +/// comes forward instead of just taking keyboard focus in the Dock. +fn show_main_window(app: &tauri::AppHandle) { + if let Some(main) = app.get_webview_window("main") { + let _ = main.unminimize(); + let _ = main.show(); + let _ = main.set_focus(); + } +} + /// macOS window chrome shared by the main window (at setup) and each workspace window: make the /// title bar tall with SYSTEM-positioned traffic lights, and paint the webview backdrop in the /// resolved theme so a fresh window doesn't flash white before the page background loads (the @@ -949,6 +1096,18 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result tauri::Result