diff --git a/.prettierignore b/.prettierignore index 6e35d98..8f6af8a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,5 @@ src-tauri/gen # Local, machine-specific Claude Code settings (gitignored) — not part of the codebase to format. .claude/settings.local.json +# Isolation-mode worktrees are gitignored full-repo copies — don't format duplicated sources. +.claude/worktrees diff --git a/CLAUDE.md b/CLAUDE.md index a8d80c0..075b3ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,23 +26,49 @@ 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. +or ⌘↵ in the switcher — the modifier also works on "Open Folder…", picking a folder straight into a +new window). Desktop also has **per-note windows** (Apple-Notes-style): ⌘↵ / ⌘-click a list row or +its ⋯ menu opens that note in its own `note-N` window — both side panels closed, focus in the editor +body, native title = the note title, per-note focus-if-open — and **⌘0** (Window ▸ Main Window, a +native accelerator) surfaces the full workspace window for THAT window's workspace, un-hiding or +creating one as needed. Per-workspace UI layout (sidebar/rail/selected folder) lives under +workspace-namespaced localStorage keys — note windows neither read nor write those (their transient +layout must not clobber the full views', and reading would eat the legacy-key migration); 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, `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 the macOS window chrome (factored as `apply_macos_chrome`, applied to every window: a -theme-aware native background for anti-flash, plus an empty unified-compact **NSToolbar** that makes -the title bar tall with SYSTEM-positioned traffic lights — macOS 26 re-runs title-bar layout on every -pass and reverts hand-set button frames, so never position the lights manually), 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. +commands (the bulk walks skip iCloud **dataless** files' content — `SF_DATALESS` in `st_flags` — +because reading one blocks on download; they list by name/mtime with empty previews, and an explicit +single-note open still materializes), the folder ops, `reveal_path`, and the **window commands** — a +label→workspace map plus a label→note map powering +`window_workspace`/`set_window_workspace`/`focus_workspace_window`/`open_workspace_window` and +`window_note`/`set_window_note`/`window_note_renamed`/`window_note_removed`/`open_note_window`, plus +`focus_main_window` (the ⌘0 fallback App invokes when no Workspace is mounted to listen). +`window_note_renamed` re-keys note-window assignments when a rename/move changes a note's rel-path, +and `window_note_removed` drops them when a note is trashed/deleted (`useNotes.rename`/`move`/`trash`/ +`remove` fire them, fire-and-forget, scoped to the caller's workspace), so per-note focus-if-open +doesn't go stale and spawn duplicate windows. All +three focus-if-open paths `unminimize()` before `show()`+`set_focus()` — set_focus alone leaves a +minimized window in the Dock. (`ws-N` and `note-N` windows are cloned from the main +window's config; note windows are 760×640, cascade centered on the opener's monitor, get their +workspace AND note assigned before the page loads, and are EXCLUDED from workspace-level +focus-if-open — `NOTE_WINDOW_PREFIX` is mirrored in `src/isTauri.ts`, keep in sync); it also registers +the **updater** + **process** plugins, sets the macOS window chrome (factored as +`apply_macos_chrome`, applied to every window: a theme-aware native background for anti-flash, plus an +empty unified-compact **NSToolbar** that makes the title bar tall with SYSTEM-positioned traffic +lights — macOS 26 re-runs title-bar layout on every pass and reverts hand-set button frames, so never +position the lights manually), and builds a **custom app menu**: the macOS "About" item emits +`menu:about` to the FOCUSED window so the frontend can open its own `AboutDialog`, and "Main Window" +(`CmdOrCtrl+0`) emits `menu:main-window` the same way — the focused window's frontend then +focuses-or-creates the workspace window for ITS OWN workspace (never dragging forward a main window +parked on another one). **Close flow:** the main window hides on ⌘W (Rust-side, macOS convention) +while `ws-N`/`note-N` windows really close — `useNotes`'s `onCloseRequested` handler flushes pending +edits then calls `preventDefault()` for `main` AND for any window whose flush could not land its +edit (an unresolved conflict re-queued it — destroying then would silently drop the edit, so the +window stays open with its conflict banner); the JS wrapper's `destroy()` needs the +`core:window:allow-destroy` capability (granted to `main`, `ws-*`, and `note-*`), so those 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`). @@ -162,12 +188,26 @@ Key modules: never a blob URL. - `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__`): + Bootstrap order: the shell's per-window assignment (`readWindowAssignment`: `window_workspace` + + `window_note`, both set before a `ws-N`/`note-N` window's page loads; a shell-assigned `ws-`/`note-` + window SKIPS the folder probe — the opener verified it moments ago — so new windows paint fast; + NOT the main window, whose assignment is registered on every in-place open and can be hours stale + after a webview reload) → the + last-active pointer → `choosing`. A note window's assignment surfaces as `windowNote` + ({workspaceId, noteId}), which App forwards to `Workspace` as `initialNoteId` only while the window + still shows that workspace — and is cleared for good on the first explicit `openWorkspace` switch, + so returning to the original workspace later restores its own last-active note, not the stale + assignment. `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` + (refreshing the registry and failing LOUDLY if the workspace is no longer in it — it's also the ⌘0 + path); `openNoteInNewWindow(noteId, title)` invokes `open_note_window` for the ACTIVE workspace; + `pickFolderForNewWindow()` probes the picked folder (timed, BEFORE touching the registry — the new + window skips its own probe on the premise the opener verified it, and a dead/huge pick must not + become the launch pointer) then opens it as its own window — persisting the registry + entry FIRST, since that's what the new window's bootstrap reads. Activation touches the registry, + registers the window's workspace, and sets the native title (skipped in note windows — their title + tracks the open note, owned by `Workspace`). 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 @@ -182,13 +222,24 @@ Key modules: not `setState`, so the markdown editor instance is never re-created mid-typing. Pending edits are flushed before every lifecycle transition (open/create/rename/remove/close/change-storage) and on `visibilitychange` / `beforeunload`; `open()` is guarded by a generation counter (wrong-note race) and - short-circuits the already-open note (no remount). Exposes `flushPending()` (teardown) and - `refresh()` (re-list after import). Takes a `NoteStore` — agnostic to which backend it is. + short-circuits the already-open note (no remount). An optional `initialNoteId` (a note window's + assignment) overrides the restore: that note loads instead of the sidecar's last-`active` pointer, + and the pointer belongs to the main window's next-launch restore — the load never writes it, and + every later metadata write from that hook (pin, appearance, wiki-link navigation, close) substitutes + the LIVE on-disk `active` at the write boundary (`writeMetadataNow` re-reads the sidecar; in-memory + `active` keeps tracking the window's own note for the conflict checks). Re-lists on window focus + (2 s throttle, `ready`-gated) — main + note windows + routinely show the same folder now, so a returning window picks up notes created/edited elsewhere. + Exposes `flushPending()` (teardown) and `refresh()` (re-list after import). Takes a `NoteStore` — + agnostic to which backend it is. - `src/hooks/useCorpus.ts` — the **shared body corpus**, loaded once (lazily, while a query is live or a note is open) and shared by full-text search AND backlinks, so `getAll()` runs once (one big IPC on desktop) and every body is held once. Derives `contentById` (raw, for snippets), `lowerById` (pre-lowercased), and `linksById` (`[[…]]` pre-extracted). Refreshes **incrementally** (re-reads only - notes whose `updatedAt` changed) and keeps `linksById`'s identity stable across a non-link edit, so a + notes whose `updatedAt` changed — plus any cached with an EMPTY body whose list preview turned + non-empty: an iCloud-dataless file materializes without an mtime bump, so the preview flip is the + only signal its content became readable; the signature includes that bit) and keeps `linksById`'s + identity stable across a non-link edit, so a plain autosave doesn't rebuild the backlink graph. Dropped on backend change. - `src/hooks/useNoteNavigation.ts` / `useNoteSearch.ts` / `useBacklinks.ts` / `useShortcuts.ts` — list cursor + focus ladder (browse/commit/escape); search-or-create scoring against the shared corpus; the @@ -225,13 +276,25 @@ Key modules: adds a manual "Check for Updates…" item; `UpdateDialog` renders the flow. - `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 + offered on the web only; the transient `loading` state renders NO gate UI — just a 300 ms-delayed + spinner — so fresh windows don't flash the welcome card), `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), + value once — note windows skip both the read and the write, start with panels closed + editor + focused (search box when the assigned note failed to load, so the window is never + keyboard-orphaned), and sync their native title + `set_window_note` assignment to the open note + ONLY once `notes.ready` — the mount run would otherwise push `set_window_note(null)` mid-load and + wipe the shell's pre-seeded assignment (a second ⌘↵ during the load would duplicate the window); + also owns the + floating **Preview badge** (read-only preview is otherwise invisible; hover swaps it to "Exit + preview", click or ⌘⇧P leaves) and the **search auto-peek** — typing a query while the sidebar is + collapsed peeks the list WITHOUT stealing focus from the box, and clearing the query or committing a + match tucks it away), `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…** (⌘-click = new window)] + 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 @@ -240,7 +303,12 @@ Key modules: 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 + sort, **Open in New Window** (row ⋯/context menu, ⌘↵, ⌘-click — desktop only, gated by the optional + `onOpenInNewWindow` prop); right-click and ⌘-click deliberately NEVER move the selection (the menu + acts via its own payload, and a row `onMouseDown` blocks the focus grab); a **folder-scope chip** + names the filter when a folder is selected while the rail is closed (click opens the rail, ✕ clears + to All Notes without switching notes); **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 @@ -248,7 +316,23 @@ Key modules: `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 + switch — the reused editor would otherwise carry the previous note's scrollTop; a note switch also + hard-resets BOTH undo histories — ProseMirror via a fresh `EditorState`, and markup-mode CodeMirror + via `editor/markupHistory.ts` (a pristine CM state snapshotted at mount becomes the template for + fresh per-note states — `replace()` is a history-recorded dispatch and `addToHistory:false` only + REMAPS old events, so without the template reset ⌘Z in markup mode walked into the previous note's + content); **linkify**: `md: {linkify: true}` with fuzzy matching OFF via `configureMd` + (`fuzzyLink/fuzzyEmail: false` — `.md` is a real TLD, so fuzzy would rewrite a `Notes.md` mention + on disk; bare URLs normalize once to `` on save) plus `editor/linkifyTypedUrls.ts`, an + InputRule that linkifies a just-typed URL on trailing whitespace with the `raw-link` attr (typed + links round-trip as the BARE url); the wiki tooltip/suggest popups are gated by `swappingRef` + (exposed as `isSwapping` to the extension) which starts TRUE at mount and brackets every content + swap — mid-swap anchors get detached by the swap's own redraw (a popup pinned to a detached element + renders stuck at the viewport's top-left), and a restored caret inside a `[[link]]` must not pop UI + unasked (the caret at a link's LEFT edge doesn't count as inside it, matching `inclusive: false`); + `.editor-pane` carries `transform: translateZ(0)` — its own compositing layer — because WKWebView + otherwise occasionally leaves a stale paint of the caret line behind after a swap/resize (a ghost + "doubled" line); the floating **selection toolbar is a vendored fixed copy** (`editor/selectionContextFix.ts` — the stock plugin never re-arms its flags after the plugin-view recreation every note switch triggers, going permanently dead; the bundle's own is disabled via `selectionContext: {config: []}` and the fixed @@ -258,7 +342,9 @@ Key modules: `ToolbarSelect` wires `onOpenChange` to the editor `focus()`, which re-closes the dropdown the instant it opens inside the floating toolbar; the local version omits that wiring so the menu opens) with `NoteTitle` and - `NotePreview`, `AttachmentsDialog` (manage attachments — list/usage/sort/ + `NotePreview` (read-only render via `@diplodoc/transform`, mirroring the editor's linkify + + fuzzy-off config; every link click routes through `openExternalUrl` instead of navigating the + surface), `AttachmentsDialog` (manage attachments — list/usage/sort/ delete + full-size view; virtualized list), `Lightbox` (shared full-size image overlay with pinch/scroll zoom + drag-pan), the editor's custom image NodeView (`editor/attachmentImageView` + `attachmentImageExtension`: resize, caption, click-to-zoom, broken state), the `[[wiki link]]` editor @@ -275,7 +361,13 @@ Key modules: - `src/App.tsx` — Gravity providers (theme, mobile, toaster) + theme persistence; wraps the app in `ErrorBoundary`. The theme key (`gravity-notes:theme`) is also read by an inline anti-flash script in `index.html` that paints the document background in the resolved theme before the bundle - loads (so launch doesn't flash white before dark) — keep the two in sync. + loads (so launch doesn't flash white before dark) — keep the two in sync. Also owns the **⌘0 + (`menu:main-window`) listener** — deliberately HERE, not in `Workspace`: App mounts exactly once + per window and never remounts, whereas `Workspace` is keyed by workspace and remounts on every + ⌃R switch, so registering the listener there leaked a duplicate per switch (a window then fired + ⌘0 for several workspaces at once). It reads the live workspace/state via a ref and calls + `openInNewWindow(activeWorkspaceId)` (fallback `focus_main_window` when no workspace is mounted — + bootstrapping, or parked on the gate). - `src/main.tsx` — app-shell + Gravity/markdown-editor stylesheet imports. ## Conventions diff --git a/README.md b/README.md index c37c5b4..b5791fc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Markdown files, and they are yours. Gravity Notes -## Features (v1) +## Features - **Choose your storage** on first run: a folder of plain `.md` files (Chromium browsers, or natively in the desktop app), or in-browser/in-app storage that works everywhere @@ -34,7 +34,11 @@ Markdown files, and they are yours. the storage menu (the `⋯` orb) - **Full-text search** across note titles _and_ bodies, ranked by relevance, with the matching passage shown as a snippet in the list (multi-word queries match all terms) -- Gravity Markdown editor (WYSIWYG + markup modes) with a read-only **preview** mode +- Gravity Markdown editor (WYSIWYG + markup modes) with a read-only **preview** mode (a floating + "Preview" chip shows while it's on — click it to exit) +- **Live URLs**: bare `https://…` links render as real links in the editor and the preview (typing one + linkifies on the trailing space), and open in your browser via ⌘-click (or a plain click in + preview). Explicit schemes only — a stray `Notes.md` never turns into a link or gets rewritten - **`[[wiki links]]`** between notes: type `[[` for a note picker, or write them by hand. They render like links (no brackets) and ⌘-click follows them — creating the note if it doesn't exist yet; unresolved links are dimmed. Stored verbatim as `[[Title]]`, so they're Obsidian-compatible @@ -48,7 +52,11 @@ Markdown files, and they are yours. accent color, and the text-column width — app-wide, per workspace, or per note (the `⋯` button or `⌘⇧I`). Per-note choices live in the folder's metadata, so they survive renames and moves - **Multiple workspaces**: open several note folders and switch with `⌃R` (or the orb menu's Open - Recent) — on the desktop, each workspace can have its own window + Recent) — on the desktop, each workspace can have its own window (`⌘-click` a recent, `⌘↵` in the + switcher — works on "Open Folder…" too) +- **Per-note windows** (desktop): `⌘↵` or `⌘-click` a note in the list (or "Open in New Window" from + its menu) to open just that note in a small window with the panels tucked away — reopening the same + note focuses its existing window, and `⌘0` brings back the workspace's main window - **Automatic updates** (desktop app): the macOS app checks for a newer release on launch and installs it in place once you confirm — plus a manual **Check for Updates…** in the storage menu. Updates are delivered through GitHub Releases and verified by signature @@ -65,6 +73,8 @@ Markdown files, and they are yours. | `⌘J` / `⌘K` | Preview next / previous note (works while editing) | | `⌘[` / `⌘]` | Go back / forward through visited notes (browser-style history) | | `Enter` | Edit the selected note | +| `⌘Enter` / `⌘-click` | Open the selected note in its own window (desktop) | +| `⌘0` | Show this workspace's main window (desktop; also in Window ▸ Main Window) | | `Esc` | Editor → list → search (then close / clear) | | `⌘L` | Jump to the search box (`⌘L` in the desktop app; browsers reserve it) | | `⌘⇧Enter` / `⌘N` | New note (`⌘N` in the desktop app; browsers reserve it) | @@ -88,7 +98,9 @@ menu the row's `⋯` button opens, at the cursor. A **folder rail** (toggle with `⌘⇧\`) lists your folders left of the notes list; it's off by default, so the app stays a two-pane view until you want it. Selecting a folder scopes the notes list to it -(**All Notes** shows everything), while search stays global. **New note** (`⌘N`) lands in the selected +(**All Notes** shows everything), while search stays global. With the rail closed, a small **folder +chip** above the list names the active scope (click it to open the rail, ✕ to go back to All Notes). +**New note** (`⌘N`) lands in the selected folder. Drag a note onto a folder to file it, or drag a folder onto another to nest it (onto **All Notes** to move it back to the root). Double-click or `F2` renames a folder; with a folder focused, `n` makes a subfolder and `⌫` removes an empty one. @@ -170,14 +182,19 @@ Key modules: - **External changes** to the open note are detected when you return focus to the tab, not live while it stays focused. - **A selected image shows a faint caret line** beside it in the editor — the browser's native - object-selection caret, which resists CSS hiding. Cosmetic only; editing is unaffected. See - `TODO.md`. + object-selection caret, which resists CSS hiding. Cosmetic only; editing is unaffected. - **Auto-update (desktop) starts from the release that introduced it.** A build without the updater (≤ 0.2.0) has to be updated by hand once; from there the macOS app updates itself in place. arm64 only. ### Backlog -- Search index update on external file updates (+ reload workspace menu item?) +- iCloud dataless files: not-yet-downloaded files list by name/mtime with an empty preview/search + body, and recover once macOS materializes them (a focus refresh picks up the filled-in preview). + Consider `startDownloadingUbiquitousItemAtURL` to kick off downloads in the background + a + "downloading…" preview state so it isn't silent. + +- Live file-watching for external edits (today the list + search index refresh when the window + regains focus, not while it stays focused) + an explicit "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/eslint.config.js b/eslint.config.js index 89f091e..01fd880 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,12 @@ import importOrderConfig from '@gravity-ui/eslint-config/import-order'; import prettierConfig from '@gravity-ui/eslint-config/prettier'; export default [ - {ignores: ['dist', 'coverage', 'src-tauri']}, + // Flat config does NOT read .gitignore, and its only default ignores are node_modules/.git. + // '.vite' matters: a stray root-level Vite dep-cache (340 multi-MB minified chunks) once made + // `eslint .` grind for 40+ minutes inside prettier/scope-analysis before anyone saw output. + // '.claude' holds machine-local settings + isolation-mode worktrees (full repo copies) — linting + // those duplicates the whole tree and reports phantom errors; nothing tracked there is JS/TS. + {ignores: ['dist', 'coverage', 'src-tauri', '.vite', '.claude']}, ...baseConfig, ...clientConfig, ...importOrderConfig, diff --git a/package-lock.json b/package-lock.json index 58ed78b..95d7d63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,15 @@ { "name": "gravity-notes", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gravity-notes", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/state": "^6.6.0", "@diplodoc/color-extension": "^0.1.1", "@diplodoc/cut-extension": "^1.0.0", "@diplodoc/file-extension": "^0.2.1", diff --git a/package.json b/package.json index c73e0dc..fe5dbc8 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "tauri:build": "tauri build" }, "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/state": "^6.6.0", "@diplodoc/color-extension": "^0.1.1", "@diplodoc/cut-extension": "^1.0.0", "@diplodoc/file-extension": "^0.2.1", 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..81d67f5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -161,6 +161,27 @@ fn modified_ms(meta: &fs::Metadata) -> f64 { .unwrap_or(0.0) } +/// Whether a file's content is evicted to the cloud (macOS APFS "dataless" files — iCloud Drive's +/// download-on-demand). READING such a file blocks until the system has downloaded it, so the bulk +/// walks (list previews, the search corpus) must skip their content instead of stalling the whole +/// app on a folder that isn't downloaded — its metadata (name, mtime) is always local. An explicit +/// single-note open still reads (and thereby downloads) the file. +#[cfg(target_os = "macos")] +fn is_dataless(meta: &fs::Metadata) -> bool { + use std::os::macos::fs::MetadataExt; + // SF_DATALESS ("file is dataless object") from `` — a super-user/system flag in the + // high half of `st_flags`, defined there as `0x40000000`. Hand-coded because libc doesn't expose + // it. Verified against the macOS 26.5 SDK header; if a future SDK ever moves it, the worst case + // is one blocking read of an evicted file (treated as local), not data loss. `st_flags()` is u32. + const SF_DATALESS: u32 = 0x4000_0000; + meta.st_flags() & SF_DATALESS != 0 +} + +#[cfg(not(target_os = "macos"))] +fn is_dataless(_meta: &fs::Metadata) -> bool { + false +} + /// Write bytes durably: write a sibling temp file, then atomically rename it over the /// target (same-filesystem rename is atomic on macOS), so a crash mid-write never /// truncates the original. Replaces the web backend's `createWritable()`/`close()` @@ -246,7 +267,13 @@ fn collect_md(root: &Path, current: &Path, full: bool, out: &mut Vec) -> .unwrap_or(&path) .to_string_lossy() .replace('\\', "/"); - let body = if full { + // A not-yet-downloaded iCloud file: list it by name/mtime but DON'T read its content — + // that would block until the system downloads it, turning a walk over a non-downloaded + // folder into a hang. Its preview/corpus body stays empty until it lands on disk + // (the focus-driven refresh picks it up); explicitly opening the note still reads it. + let body = if is_dataless(&meta) { + String::new() + } else if full { String::from_utf8_lossy(&fs::read(&path)?).into_owned() } else { let mut buf = Vec::new(); @@ -696,17 +723,24 @@ 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 / close) plus `window_note_renamed` / `window_note_removed` (a rename, +/// move, trash, or delete from ANOTHER window in the same workspace re-keys or drops the entry), +/// 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 +751,39 @@ 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); + +/// Single-note window size (logical px) — a single column of prose, not a three-pane workspace. +const NOTE_WINDOW_WIDTH: f64 = 760.0; +const NOTE_WINDOW_HEIGHT: f64 = 640.0; + +/// 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()) } @@ -776,6 +834,9 @@ fn focus_workspace_window( 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); + // Unminimize first — set_focus on a miniaturized window only takes keyboard focus + // in the Dock (see show_main_window). + let _ = target.unminimize(); let _ = target.show(); let _ = target.set_focus(); return true; @@ -812,6 +873,9 @@ async fn open_workspace_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); + // Unminimize first — set_focus alone leaves a minimized window in the Dock + // (see show_main_window). + let _ = target.unminimize(); let _ = target.show(); let _ = target.set_focus(); return Ok(true); @@ -848,6 +912,221 @@ 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, + window: tauri::WebviewWindow, + 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, cascade_step) = { + 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); + // Unminimize first — set_focus alone leaves a minimized window in the Dock + // (see show_main_window). + let _ = target.unminimize(); + let _ = target.show(); + let _ = target.set_focus(); + return Ok(true); + } + st.labels.remove(&found); + st.notes.remove(&found); + } + let n = NEXT_NOTE_WINDOW.fetch_add(1, Ordering::SeqCst); + let label = format!("{NOTE_WINDOW_PREFIX}{n}"); + // 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, n) + }; + // 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 = NOTE_WINDOW_WIDTH; + config.height = NOTE_WINDOW_HEIGHT; + // Position: centered-ish on the opener's monitor (a third down, like a system dialog), + // cascading down-right per note window so consecutive opens don't stack exactly. Anchored to + // the SCREEN, not the opener: gluing the new window onto the opener's corner buried the main + // window under a nearly-aligned copy of the same note — which reads as a "doubled" editor. + // Wraps after 8 steps; without monitor info the window just centers (from_config default). + if let Ok(Some(monitor)) = window.current_monitor() { + let scale = monitor.scale_factor(); + let mpos: tauri::LogicalPosition = monitor.position().to_logical(scale); + let msize: tauri::LogicalSize = monitor.size().to_logical(scale); + let step = f64::from((cascade_step - 1) % 8) * 28.0; + config.x = Some(mpos.x + ((msize.width - NOTE_WINDOW_WIDTH).max(0.0) / 2.0) + step); + config.y = Some(mpos.y + ((msize.height - NOTE_WINDOW_HEIGHT).max(0.0) / 3.0) + step); + } + 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(); + } +} + +/// Frontend fallback for Window ▸ Main Window (⌘0) when the focused window has no workspace +/// mounted (still bootstrapping, or parked on the storage gate after a failed probe): there is +/// nothing workspace-scoped to focus, so plainly re-show the (possibly ⌘W-hidden) main window. +#[tauri::command] +fn focus_main_window(app: tauri::AppHandle) { + show_main_window(&app); +} + +/// Re-key note-window assignments after a note rename/move: any note window in the CALLER's +/// workspace showing `old_id` is re-pointed at `new_id`, so per-note focus-if-open keeps +/// matching. Without this, the map goes stale the moment another window renames the note — +/// "open in new window" for the new path would then spawn a duplicate while the old window's +/// entry pointed at a path that no longer exists. The workspace scope comes from the caller's +/// own label registration (the rename ran in that window), so same rel-paths in OTHER +/// workspaces are untouched. +#[tauri::command] +fn window_note_renamed( + window: tauri::WebviewWindow, + state: tauri::State<'_, WindowWorkspaces>, + old_id: String, + new_id: String, +) { + let mut st = state.0.lock().unwrap(); + let Some(ws_id) = st.labels.get(window.label()).cloned() else { + return; + }; + let WindowState { labels, notes, .. } = &mut *st; + remap_note_windows(labels, notes, &ws_id, &old_id, &new_id); +} + +/// Pure core of `window_note_renamed`, split out for the unit test. +fn remap_note_windows( + labels: &HashMap, + notes: &mut HashMap, + ws_id: &str, + old_id: &str, + new_id: &str, +) { + for (label, note) in notes.iter_mut() { + if note.as_str() == old_id && labels.get(label).map(String::as_str) == Some(ws_id) { + new_id.clone_into(note); + } + } +} + +/// Drop note-window assignments for a note that was trashed or permanently deleted (rel-path +/// `note_id`) in the CALLER's workspace — the delete-path mirror of `window_note_renamed`, keeping +/// the `notes` map free of ids no longer openable. Without it, a note window left showing an +/// orphaned note (trashed from another window) would keep answering per-note focus-if-open for a +/// dead id — e.g. a later note of the same name would focus the orphan instead of opening fresh. +/// Workspace-scoped (the delete ran in the caller's window), so the same rel-path in another +/// workspace is untouched. +#[tauri::command] +fn window_note_removed( + window: tauri::WebviewWindow, + state: tauri::State<'_, WindowWorkspaces>, + note_id: String, +) { + let mut st = state.0.lock().unwrap(); + let Some(ws_id) = st.labels.get(window.label()).cloned() else { + return; + }; + let WindowState { labels, notes, .. } = &mut *st; + unassign_note_windows(labels, notes, &ws_id, ¬e_id); +} + +/// Pure core of `window_note_removed`, split out for the unit test. +fn unassign_note_windows( + labels: &HashMap, + notes: &mut HashMap, + ws_id: &str, + note_id: &str, +) { + notes.retain(|label, note| { + !(note.as_str() == note_id && labels.get(label).map(String::as_str) == Some(ws_id)) + }); +} + /// 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 +1228,18 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result tauri::Result