Compare commits

...

51 Commits

Author SHA1 Message Date
Test
7df1961172 feat: persist note list sort preference in type file frontmatter
Sort preferences for each type's note list are now stored in the type
file's frontmatter (e.g. `sort: modified:desc` in `type/person.md`)
instead of localStorage. This makes preferences portable with the vault
and versionable in git.

- Add `sort` field to Rust Frontmatter/VaultEntry and TS VaultEntry
- NoteList reads sort from type entry's frontmatter when viewing a type
- Sort changes write to type file via update_frontmatter
- Silent migration from localStorage on first access per type
- Relationship group sorts still use localStorage (no type file)
- Fallback to `modified:desc` when no sort preference exists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 12:18:40 +01:00
Test
ec74f86d53 fix: restore theming system after dark-editor merge regression
The merge of the dark-theme-editor feature (cadb350) into the
themes-editable rewrite (19bc3c6) broke the entire theming UI:
themes weren't listed, switching didn't work, and new theme creation
was broken.

Root causes and fixes:
- Stale theme ID from old JSON system ("untitled-2") never cleared:
  added detection that clears IDs not matching any known vault theme,
  with a ref to skip IDs just set by switchTheme/createTheme
- set_active_theme Rust command only accepted String, not Option:
  now accepts Option<String> so null can clear the setting
- Theme colors empty in UI: entryToThemeFile now extracts colors
  from frontmatter content via extractColorsFromContent
- color-scheme/data-theme-mode not set: added updateColorScheme
  and clearColorScheme to sync DOM attributes on theme apply/clear
- isDark broken in Tauri (allContent is {}): moved isDark tracking
  into useThemeApplier as state, updated when vars are applied
- SettingsPanel passed activeThemeId as name to createTheme: fixed
  to call createTheme() with no arguments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:53:00 +01:00
Luca Rossi
d86dfbfcb6 refactor: extract useEditorSaveWithLinks and useNavigationGestures from App.tsx (#186)
App.tsx had 539 lines and 112 git touches in 30 days — highest churn in
the codebase. Two self-contained hooks were defined inline with no
dependency on App internals:

- useEditorSaveWithLinks: wraps useEditorSave to also extract and update
  outgoing wikilinks on save. Moved to src/hooks/useEditorSaveWithLinks.ts.

- useNavigationGestures: registers mouse button 3/4 back/forward and
  macOS trackpad horizontal swipe listeners. Moved to
  src/hooks/useNavigationGestures.ts.

App.tsx shrinks from 539 → 473 lines (-66 lines). Both hooks are now
independently testable and reusable.

Co-authored-by: Test <test@test.com>
2026-03-03 07:10:34 +01:00
Test
e77208ec34 style: cargo fmt git.rs 2026-03-03 02:49:25 +01:00
Test
818707603e fix: add useIndexing import and hook call, wire indexingProgress and onRemoveVault to StatusBar 2026-03-03 02:48:10 +01:00
Test
fa3f9adccc chore: add sync-conflict-resolution design file
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:45:42 +01:00
Test
bf6312000e feat: add sync conflict resolution — resolve merge conflicts in-app
- Add git_resolve_conflict and git_commit_conflict_resolution Rust commands
- Create useConflictResolver hook for per-file resolution state management
- Create ConflictResolverModal with keyboard shortcuts (K/T/O/Enter/Esc)
- Make StatusBar conflict chip clickable to open resolver modal
- Add 'Resolve Conflicts' command to command palette (conditional)
- Pause auto-pull while conflict resolver modal is open
- Tests: 5 new Rust tests, 10 new hook tests, 1 new auto-sync test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:45:42 +01:00
Test
832181b9a9 style: cargo fmt --all 2026-03-03 02:44:00 +01:00
Test
bc011692cd style: rustfmt lib.rs 2026-03-03 02:43:15 +01:00
Test
0604a13cdd fix: add missing X import and onRemoveVault prop to StatusBar after rebase 2026-03-03 02:38:44 +01:00
Test
eb77607401 feat: auto-index vault on open + qmd auto-install + status bar progress
Search now works out of the box on any vault without manual qmd
installation. On vault open, the app checks the index status and
auto-triggers background indexing (qmd update + embed) when needed.
If qmd is not installed, it auto-installs via bun.

Changes:
- New indexing.rs module: find_qmd_binary (cached), check_index_status,
  ensure_collection, run_full_index with progress callbacks,
  run_incremental_update, auto_install_qmd
- search.rs: delegates to indexing::find_qmd_binary (removes duplication)
- lib.rs: new Tauri commands (get_index_status, start_indexing,
  trigger_incremental_index)
- useIndexing hook: auto-triggers on vault open, listens for
  indexing-progress events, auto-dismisses complete state after 5s
- StatusBar: IndexingBadge shows phase + progress counts with spinner
- App.tsx: wires up useIndexing, triggers incremental index on file save
- design/search-bundle-qmd.pen: documents indexing progress UI states

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:37:49 +01:00
Test
e305c29e6e style: rustfmt vault_list.rs 2026-03-03 02:36:33 +01:00
Test
5b1804e5e1 feat: vault management — remove vault from list and restore Getting Started
Add ability to remove vaults from the app list without deleting files on disk,
and restore the bundled Getting Started demo vault when needed.

Changes:
- Rust: add hidden_defaults field to VaultList for tracking removed default vaults
- useVaultSwitcher: add removeVault() and restoreGettingStarted() with auto-switch
- useCommandRegistry: add 'Remove Vault from List' and 'Restore Getting Started Vault' commands
- StatusBar: add X button per vault item in vault menu dropdown
- 25 new tests covering removal, restore, edge cases, and command palette

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:36:33 +01:00
Luca Rossi
2c5cfe2923 feat: sort picker shows custom frontmatter properties (#185)
The sort dropdown now discovers all scalar properties (string, number,
boolean, date) across notes in the current list and shows them below a
separator after the built-in options. Properties that no longer exist
in the current list are gracefully handled by falling back to Modified.

Rust backend extracts custom properties during vault scan so they are
available on every VaultEntry without loading file content on demand.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:31:18 +01:00
Luca Rossi
35144aedfb feat: show archived note indicator banner in editor (#183)
Adds a subtle ArchivedNoteBanner component below the breadcrumb bar
when a note is archived. Banner includes:
- Muted gray background with archive icon + 'Archived' label
- 'Unarchive' button (ArrowUUpLeft icon) wired to same handler as Cmd+E
- Keyboard hint shown in button title

Editor remains fully editable (banner is purely informational).
Indicator appears/disappears reactively via entry.archived from store.

Co-authored-by: Test <test@test.com>
2026-03-03 02:31:10 +01:00
Luca Rossi
4d7252c78f feat: add command palette toggles for all BreadcrumbBar panels (#184)
Adds toggle commands to Cmd+K for:
- Toggle Properties Panel (prop/inspector)
- Toggle Diff Mode (diff) — disabled without note changes
- Toggle Backlinks (back) — disabled without note

Updates:
- useCommandRegistry.ts: 5 new commands with proper disabled states
- useAppCommands.ts: wires onToggleDiff and onToggleBacklinks
- Editor.tsx: added diffToggleRef prop (mirrors rawToggleRef pattern)
- App.tsx: creates diffToggleRef, passes to Editor, wires commands

10 new tests in useCommandRegistry.test.ts covering all new commands.

Co-authored-by: Test <test@test.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-03 02:00:48 +01:00
Luca Rossi
eb9a3d889f fix: show all direct relationship properties in note list sidebar (#182)
The GroupBuilder's `seen` set was causing direct relationship properties
to be suppressed. Reverse/computed groups (Children, Events) ran before
the entity's own relationship keys, consuming entries into `seen` and
preventing direct properties like "Belongs to" and "Notes" from appearing.

Fix: process all direct relationship keys from entity.relationships
before the reverse groups, so direct properties always take priority.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:37:35 +01:00
Luca Rossi
6d2988b722 feat: trashed notes read-only with visible banner in editor (#181)
* feat: make trashed notes read-only with visible banner in editor

When a note is in the Trash, the editor now shows a banner below the
breadcrumb ("This note is in the Trash") with Restore and Delete
permanently buttons. The BlockNote editor is set to read-only mode,
preventing accidental edits while still allowing navigation and copy.

- Add TrashedNoteBanner component with restore/delete actions
- Pass editable={false} to BlockNoteView when note is trashed
- Add delete_note Rust command for permanent file deletion
- Wire onDeleteNote through Editor → EditorContent → App
- Extract EditorBody to reduce EditorContent cyclomatic complexity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: rustfmt fix

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:37:25 +01:00
Test
a33a000c57 fix: remove persistLastVault assertions from onboarding test 2026-03-03 00:59:09 +01:00
Test
f0b456bb8c fix: remove stale persistLastVault and unused imports after rebase 2026-03-03 00:57:47 +01:00
Test
b489fa8e3e fix: persist vault list across app updates
Vault list was stored only in React useState, lost on every app restart
or update. Now persisted to ~/.config/com.laputa.app/vaults.json via
Rust backend commands (load_vault_list, save_vault_list).

- Add vault_list.rs module with VaultEntry/VaultList types and JSON I/O
- Register load_vault_list/save_vault_list Tauri commands
- Extract vaultListStore.ts utility for frontend persistence calls
- Rewrite useVaultSwitcher to load on mount and persist on change
- Show unavailable vaults greyed out with warning icon instead of
  silently removing them
- Add mock handlers for browser/test environments
- Add useVaultSwitcher tests covering persistence, availability, dedup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:57:05 +01:00
Test
1a92b4694c fix: persist last vault path so app reopens correct vault after update
The vault path was stored only in React state (useState), which resets
on every app restart. Now the last active vault path is written to
~/Library/Application Support/com.laputa.app/last-vault.txt on every
vault switch, and loaded on startup. If the saved vault no longer exists,
the existing onboarding flow shows the vault picker instead of silently
falling back to the demo vault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:33:55 +01:00
Test
6b9ff9a4a2 feat: add "New Type" command to command palette (Cmd+K) 2026-03-03 00:31:10 +01:00
Luca Rossi
97d2182c0e test: add asserting wikilink navigation E2E test — screenshot.spec.ts test had no expect() calls (#180)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 23:55:57 +01:00
Test
80baa74175 feat: add 'Check for Updates' command to command palette
Users can now trigger an update check from Cmd+K → "Check for Updates"
without leaving the app. Shows toast for up-to-date/error states,
and the existing UpdateBanner for available updates. Command is
disabled while an update is downloading or ready to install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:53:37 +01:00
Test
f71e6d07e7 feat: rename demo vault from 'Demo v2' to 'Getting Started', remove 'Laputa' vault entry
- Rename 'Demo v2' → 'Getting Started' in vault switcher
- Remove 'Laputa' personal vault from default vault list
- Update default Getting Started vault path from Documents/Laputa to Documents/Getting Started
- Update mock handlers and tests to reflect new vault path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:49:36 +01:00
Test
b8a0702e3c fix: use compile-time env!() macro for BUILD_NUMBER instead of runtime std::env::var
The build number was always showing 'b0' because build.rs sets BUILD_NUMBER
via cargo:rustc-env (compile-time), but lib.rs was reading it with
std::env::var (runtime) which falls back to "0" when unset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:42:24 +01:00
Luca Rossi
c48f337c4d fix: bundle mcp-server into release app so AI Chat works (#178)
* feat: bundle mcp-server into release app so AI Chat works

- Add esbuild bundle script (scripts/bundle-mcp-server.mjs) that compiles
  mcp-server/index.js and ws-bridge.js into self-contained CJS bundles
- Output goes to src-tauri/resources/mcp-server/ (gitignored)
- Add Tauri resources config to copy bundles into Contents/Resources/mcp-server/
- Update mcp_server_dir() to look in Contents/Resources/ (not Contents/) in release
- Add bundle-mcp npm script; hook it into tauri beforeBuildCommand
- Exclude generated resources from ESLint

Previously AI Chat showed 'mcp-server not found at .../Contents/mcp-server'
because the release path lacked the 'Resources' segment and no files were bundled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: bundle mcp-server resources before Rust tests

* fix: add mcp-server as pnpm workspace package so esbuild can resolve its deps in CI

* fix: exclude src-tauri/target from eslint to fix CI lint failure

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 23:31:04 +01:00
Test
b32d46f482 style: fix rustfmt formatting in cache test assertions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:29:27 +01:00
Test
fd100e1c3b fix: sync changes badge count with list by invalidating stale vault cache
When .laputa-cache.json was committed to git, cloned vaults carried
absolute paths from the original machine. The badge (from get_modified_files)
used fresh local paths while the list filtered entries by stale cached paths,
causing a mismatch.

Three-layer fix:
- Invalidate cache when vault_path differs from current machine (CACHE_VERSION bump)
- Exclude .laputa-cache.json via .git/info/exclude to prevent future commits
- Defense-in-depth: match entries by relative path suffix in NoteList
- Surface error message when modified files fetch fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:29:27 +01:00
Test
249db95c9e fix: correct git_uncommitted_files call after rebase conflict 2026-03-02 23:22:31 +01:00
Test
3c27b63908 fix: add aria-label to InlineRenameInput for test accessibility 2026-03-02 23:20:49 +01:00
Test
a246de4483 style: apply rustfmt to cache.rs test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 23:17:36 +01:00
Test
2793024904 feat: add Rename section to sidebar context menu
- Add handleRenameSection to useEntryActions: writes/deletes 'sidebar label'
  frontmatter key on the Type note with optimistic in-memory update
- Add InlineRenameInput to SidebarParts: autoFocus input rendered in-place
  of section title, submits on Enter/blur, cancels on Escape
- Add 'Rename section…' as first item in sidebar section context menu
- Wire onRenameSection from App.tsx through Sidebar props
- Add 10 new tests (4 unit, 6 component) covering the full rename flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 23:17:36 +01:00
Test
f0ef9cacec fix: update_same_commit picks up modified files, not only new ones
The cache invalidation only re-parsed new untracked files when the git
HEAD hash was unchanged. Modified (uncommitted) files were served stale,
so editing a Type note's 'sidebar label' frontmatter key had no effect
until the next git commit triggered a full incremental diff.

Replace git_uncommitted_new_files (status ?? / A only) with
git_uncommitted_files (all porcelain entries) and apply the same
remove-stale + re-parse logic used by update_different_commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 23:17:26 +01:00
Test
d2538e121f style: apply rustfmt to theme.rs and vault/cache.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:12:36 +01:00
Test
531666b031 fix: remove unused is_new_file_status function
Clippy flagged dead code in vault/cache.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:11:30 +01:00
Test
e239d71a48 fix: add isDark to useThemeManager return value 2026-03-02 23:09:02 +01:00
Test
e2f1fe239e fix: remove stale deriveThemeVariables call and update test assertions after conflict resolution 2026-03-02 23:07:58 +01:00
Test
8495f0e2e6 test: add theme command registry tests and minor fixes 2026-03-02 23:06:12 +01:00
Test
19bc3c67e3 wip: themes-editable — theme management system WIP (13 files, 1014 insertions) 2026-03-02 23:06:12 +01:00
Test
628d97d909 chore: sync Sidebar.tsx dragHandleProps removal from main 2026-03-02 21:57:43 +01:00
Test
aa6b31317c chore: sync SidebarParts dragHandle removal from main 2026-03-02 21:57:43 +01:00
Test
b24ba8a4c6 fix: position context menu and customize popover at right-click coordinates
The CustomizeIconColor popover was hardcoded to (20, 100) — always wrong.
Bottom sections also failed because the context menu could extend below the
viewport, making "Customize icon & color" unreachable.

- Capture context menu click position and use it to position the customize
  popover (via new `customizePos` state in Sidebar)
- Clamp context menu position to flip above the click point when near the
  bottom of the viewport (CONTEXT_MENU_HEIGHT guard)
- Clamp customize popover to stay within viewport bounds via clampToViewport()
- Add 6 tests covering context menu flow, positioning, and color callback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:57:43 +01:00
Test
b3f47f4507 chore: remove dragHandleProps from SectionContent destructuring 2026-03-02 21:56:50 +01:00
Test
6fcd0552d0 chore: remove remaining dragHandleProps from SortableSection 2026-03-02 21:55:31 +01:00
Test
8747a84453 style: remove drag handle icon from sidebar sections 2026-03-02 21:29:16 +01:00
Test
2eb3080050 style: align section icons with main nav items in sidebar 2026-03-02 21:29:16 +01:00
Test
99b64c0fac style: remove letter-spacing from status/property labels in inspector 2026-03-02 21:29:16 +01:00
Test
b2a68a9a67 style: reduce editor body font to 15px, apply -0.5 letter-spacing to h2/h3/h4 2026-03-02 21:29:16 +01:00
Test
f5bbcb81a4 fix: defer H1 selection to second rAF so content swap completes first
The previous implementation called selectFirstHeading() in the same rAF
as editor.focus(), but the new note's content (applied via queueMicrotask
inside a React effect) hadn't been swapped in yet. React's MessageChannel
scheduler runs the re-render between animation frames, so the heading
block didn't exist in the TipTap document when the selection ran.

Fix: move selectFirstHeading() into a nested requestAnimationFrame inside
doFocus(). Between rAF 1 (focus) and rAF 2 (select), all pending
macrotasks — React's re-render + the queueMicrotask content swap — run
to completion, guaranteeing the H1 block is in the document before we
try to select it.

Updated tests: add rAF mock to the timeout-path select test (which now
needs it since selection is deferred via rAF); add a regression test
that explicitly verifies focus in rAF1 and selection in rAF2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:27:26 +01:00
102 changed files with 7437 additions and 1028 deletions

View File

@@ -1 +1 @@
81859
45402

View File

@@ -60,6 +60,9 @@ jobs:
- name: Run frontend tests
run: pnpm test
- name: Bundle MCP server resources (required by Tauri build)
run: node scripts/bundle-mcp-server.mjs
- name: Run Rust tests
run: cargo test --manifest-path=src-tauri/Cargo.toml

3
.gitignore vendored
View File

@@ -42,3 +42,6 @@ final_selection.py
.claude-done
.claude-blocked
src-tauri/target
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
src-tauri/resources/

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -0,0 +1 @@
{"children":[{"id":"status-bar-indexing","type":"frame","name":"StatusBar - Indexing Progress States","layout":"vertical","x":0,"y":0,"width":1400,"height":400,"gap":24,"padding":24,"fill":"#F7F6F3","children":[{"id":"title","type":"text","content":"StatusBar Indexing Progress - search-bundle-qmd","fontSize":20,"fontWeight":"600","fill":"#37352F"},{"id":"desc","type":"text","content":"The indexing badge appears in the StatusBar left section, after the pending changes badge. It shows the current indexing phase with a spinner icon (Loader2) during active phases and a Search icon when complete.","fontSize":14,"fill":"#9B9A97","width":900},{"id":"states","type":"frame","name":"States","layout":"vertical","gap":16,"width":1350,"children":[{"id":"state-idle","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-idle","type":"text","content":"idle:","fontSize":12,"fontWeight":"600","fill":"#9B9A97","width":100},{"id":"val-idle","type":"text","content":"(hidden - no badge shown)","fontSize":12,"fill":"#9B9A97"}]},{"id":"state-installing","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-install","type":"text","content":"installing:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-install","type":"text","content":"| [spinner] Installing search\u2026","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-scanning","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-scan","type":"text","content":"scanning:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-scan","type":"text","content":"| [spinner] Indexing\u2026 342/1,057","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-embedding","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-embed","type":"text","content":"embedding:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-embed","type":"text","content":"| [spinner] Embedding\u2026 50/200","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-complete","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-done","type":"text","content":"complete:","fontSize":12,"fontWeight":"600","fill":"#22c55e","width":100},{"id":"val-done","type":"text","content":"| [search icon] Index ready (auto-dismisses after 5s)","fontSize":12,"fill":"#22c55e"}]},{"id":"state-error","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-err","type":"text","content":"error:","fontSize":12,"fontWeight":"600","fill":"#f97316","width":100},{"id":"val-err","type":"text","content":"| [search icon] Index error (orange accent)","fontSize":12,"fill":"#f97316"}]}]},{"id":"flow","type":"frame","name":"Flow","layout":"vertical","gap":8,"width":1350,"children":[{"id":"flow-title","type":"text","content":"Auto-indexing Flow","fontSize":16,"fontWeight":"600","fill":"#37352F"},{"id":"flow-desc","type":"text","content":"1. Vault opens \u2192 useIndexing checks index status via get_index_status\n2. If qmd missing or collection missing or pending embeds \u2192 triggers start_indexing\n3. Rust emits indexing-progress events (installing \u2192 scanning \u2192 embedding \u2192 complete)\n4. StatusBar shows IndexingBadge with spinner + phase label + progress counts\n5. On file save \u2192 trigger_incremental_index runs qmd update in background\n6. Complete phase auto-dismisses after 5 seconds","fontSize":13,"fill":"#37352F","width":900}]}]}],"variables":{}}

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -0,0 +1 @@
{"children":[{"id":"trashed-banner-frame","type":"frame","name":"Trashed Note Banner","x":0,"y":0,"width":720,"height":340,"fill":"#FFFFFF","cornerRadius":[8,8,8,8],"children":[{"id":"breadcrumb","type":"frame","name":"Breadcrumb Bar","width":720,"height":45,"fill":"#FFFFFF","layout":"horizontal","mainAxisAlignment":"space-between","crossAxisAlignment":"center","padding":16,"children":[{"id":"breadcrumb-left","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","children":[{"id":"type-label","type":"text","content":"Note","fontSize":12,"textColor":"#6B7280"},{"id":"sep","type":"text","content":"","fontSize":12,"textColor":"#6B7280"},{"id":"title","type":"text","content":"My Trashed Note","fontSize":12,"fontWeight":"600","textColor":"#1F2937"},{"id":"dot","type":"text","content":"·","fontSize":12,"textColor":"#6B7280"},{"id":"words","type":"text","content":"1,234 words","fontSize":12,"textColor":"#6B7280"}]},{"id":"breadcrumb-right","type":"frame","layout":"horizontal","gap":12,"crossAxisAlignment":"center","children":[{"id":"restore-icon","type":"text","content":"↺","fontSize":14,"textColor":"#6B7280"},{"id":"inspector-icon","type":"text","content":"⚙","fontSize":14,"textColor":"#6B7280"}]}]},{"id":"banner","type":"frame","name":"Trashed Note Banner","width":720,"height":32,"fill":"#FEF2F2","layout":"horizontal","crossAxisAlignment":"center","gap":8,"padding":16,"children":[{"id":"trash-icon","type":"text","content":"🗑","fontSize":12,"textColor":"#DC2626"},{"id":"banner-text","type":"text","content":"This note is in the Trash","fontSize":12,"textColor":"#6B7280","width":400},{"id":"restore-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"restore-icon-2","type":"text","content":"↺","fontSize":11,"textColor":"#2563EB"},{"id":"restore-label","type":"text","content":"Restore","fontSize":11,"textColor":"#2563EB"}]},{"id":"delete-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"delete-icon","type":"text","content":"🗑","fontSize":11,"textColor":"#DC2626"},{"id":"delete-label","type":"text","content":"Delete permanently","fontSize":11,"textColor":"#DC2626"}]}]},{"id":"editor-area","type":"frame","name":"Editor (read-only, muted)","width":720,"height":260,"fill":"#FAFAFA","padding":32,"children":[{"id":"h1","type":"text","content":"My Trashed Note","fontSize":24,"fontWeight":"700","textColor":"#9CA3AF"},{"id":"p1","type":"text","content":"This is the content of a trashed note. The editor is non-editable.","fontSize":14,"textColor":"#9CA3AF","y":40},{"id":"p2","type":"text","content":"Navigation and copy still work, but typing and editing are disabled.","fontSize":14,"textColor":"#9CA3AF","y":64}]}]}],"variables":{}}

View File

@@ -0,0 +1,281 @@
{
"children": [
{
"type": "frame",
"id": "vault_menu_remove",
"name": "Vault Menu — Remove from List",
"x": 0,
"y": 0,
"width": 380,
"height": 320,
"fill": "#F7F6F3",
"layout": "vertical",
"gap": 16,
"padding": [24, 24, 24, 24],
"theme": { "Mode": "Light" },
"children": [
{
"type": "text",
"id": "vault_menu_title",
"content": "Vault Menu — Remove Action",
"fill": "#37352F",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
},
{
"type": "frame",
"id": "vault_menu_dropdown",
"name": "Vault Dropdown",
"width": "fill_container",
"height": "fit_content",
"fill": "#FFFFFF",
"cornerRadius": [6, 6, 6, 6],
"stroke": "#E9E9E7",
"strokeThickness": 1,
"layout": "vertical",
"padding": [4, 4, 4, 4],
"gap": 0,
"children": [
{
"type": "frame",
"id": "vault_item_active",
"name": "Active Vault Item",
"width": "fill_container",
"height": 32,
"fill": "#EBEBEA",
"cornerRadius": [4, 4, 4, 4],
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "space-between",
"padding": [4, 8, 4, 8],
"children": [
{
"type": "frame",
"id": "vault_item_active_left",
"layout": "horizontal",
"alignItems": "center",
"gap": 6,
"children": [
{ "type": "text", "id": "vault_check", "content": "✓", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 12 },
{ "type": "text", "id": "vault_label_active", "content": "My Vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "remove_btn_active",
"name": "Remove Button",
"width": 18,
"height": 18,
"cornerRadius": [3, 3, 3, 3],
"layout": "vertical",
"alignItems": "center",
"justifyContent": "center",
"children": [
{ "type": "text", "id": "x_icon_active", "content": "×", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
]
}
]
},
{
"type": "frame",
"id": "vault_item_other",
"name": "Other Vault Item",
"width": "fill_container",
"height": 32,
"cornerRadius": [4, 4, 4, 4],
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "space-between",
"padding": [4, 8, 4, 8],
"children": [
{
"type": "frame",
"id": "vault_item_other_left",
"layout": "horizontal",
"alignItems": "center",
"gap": 6,
"children": [
{ "type": "text", "id": "vault_spacer", "content": " ", "fill": "transparent", "fontFamily": "Inter", "fontSize": 12 },
{ "type": "text", "id": "vault_label_other", "content": "Work Vault", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
]
},
{
"type": "frame",
"id": "remove_btn_other",
"name": "Remove Button",
"width": 18,
"height": 18,
"cornerRadius": [3, 3, 3, 3],
"layout": "vertical",
"alignItems": "center",
"justifyContent": "center",
"children": [
{ "type": "text", "id": "x_icon_other", "content": "×", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
]
}
]
},
{
"type": "frame",
"id": "vault_separator",
"name": "Separator",
"width": "fill_container",
"height": 1,
"fill": "#E9E9E7"
},
{
"type": "frame",
"id": "vault_open_folder",
"name": "Open Local Folder",
"width": "fill_container",
"height": 32,
"cornerRadius": [4, 4, 4, 4],
"layout": "horizontal",
"alignItems": "center",
"gap": 6,
"padding": [4, 8, 4, 8],
"children": [
{ "type": "text", "id": "folder_icon", "content": "📁", "fontFamily": "Inter", "fontSize": 12 },
{ "type": "text", "id": "open_folder_label", "content": "Open local folder", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
]
}
]
},
{
"type": "text",
"id": "vault_menu_note",
"content": "× button removes vault from list without deleting files.\nAvailable when 2+ vaults in list.",
"fill": "#787774",
"fontFamily": "Inter",
"fontSize": 11,
"width": "fill_container"
}
]
},
{
"type": "frame",
"id": "cmd_k_vault_commands",
"name": "Cmd+K — Vault Commands",
"x": 420,
"y": 0,
"width": 520,
"height": 320,
"fill": "#F7F6F3",
"layout": "vertical",
"gap": 16,
"padding": [24, 24, 24, 24],
"theme": { "Mode": "Light" },
"children": [
{
"type": "text",
"id": "cmdk_title",
"content": "Command Palette — Vault Commands",
"fill": "#37352F",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
},
{
"type": "frame",
"id": "cmdk_palette",
"name": "Command Palette",
"width": "fill_container",
"height": "fit_content",
"fill": "#FFFFFF",
"cornerRadius": [8, 8, 8, 8],
"stroke": "#E9E9E7",
"strokeThickness": 1,
"layout": "vertical",
"padding": [0, 0, 0, 0],
"gap": 0,
"children": [
{
"type": "frame",
"id": "cmdk_search",
"name": "Search Input",
"width": "fill_container",
"height": 44,
"layout": "horizontal",
"alignItems": "center",
"padding": [0, 16, 0, 16],
"children": [
{ "type": "text", "id": "cmdk_search_text", "content": "vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14 }
]
},
{
"type": "frame",
"id": "cmdk_separator",
"width": "fill_container",
"height": 1,
"fill": "#E9E9E7"
},
{
"type": "frame",
"id": "cmdk_group_header",
"name": "Settings Group",
"width": "fill_container",
"height": 28,
"layout": "horizontal",
"alignItems": "center",
"padding": [0, 12, 0, 12],
"children": [
{ "type": "text", "id": "group_label", "content": "Settings", "fill": "#B4B4B4", "fontFamily": "Inter", "fontSize": 11, "fontWeight": "500" }
]
},
{
"type": "frame",
"id": "cmdk_open_vault",
"name": "Open Vault Command",
"width": "fill_container",
"height": 36,
"layout": "horizontal",
"alignItems": "center",
"padding": [0, 12, 0, 12],
"children": [
{ "type": "text", "id": "cmd_open_vault", "content": "Open Vault…", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
]
},
{
"type": "frame",
"id": "cmdk_remove_vault",
"name": "Remove Vault Command (highlighted)",
"width": "fill_container",
"height": 36,
"fill": "#E8F4FE",
"layout": "horizontal",
"alignItems": "center",
"padding": [0, 12, 0, 12],
"children": [
{ "type": "text", "id": "cmd_remove_vault", "content": "Remove Vault from List", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
]
},
{
"type": "frame",
"id": "cmdk_restore_gs",
"name": "Restore Getting Started Command",
"width": "fill_container",
"height": 36,
"layout": "horizontal",
"alignItems": "center",
"padding": [0, 12, 0, 12],
"children": [
{ "type": "text", "id": "cmd_restore_gs", "content": "Restore Getting Started Vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
]
}
]
},
{
"type": "text",
"id": "cmdk_note",
"content": "• 'Remove Vault from List' removes active vault (disabled if only 1 vault)\n• 'Restore Getting Started Vault' shown when GS vault is hidden\n• Both accessible via Cmd+K search with 'vault' keyword",
"fill": "#787774",
"fontFamily": "Inter",
"fontSize": 11,
"width": "fill_container"
}
]
}
],
"variables": {}
}

View File

@@ -231,3 +231,30 @@ test('full create note flow', async ({ page }) => {
await page.screenshot({ path: 'test-results/core-create-note.png', fullPage: true })
})
// --- Flow 8: Wiki-link navigation ---
test('clicking a wikilink opens the target note in a new tab', async ({ page }) => {
// Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink
await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click()
await page.waitForTimeout(300)
// Verify we opened the right note
await expect(page.locator('.editor__tab--active')).toHaveText(/Manage Sponsorships/)
// Click the wikilink — use mouse.click to fire real mousedown
const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' })
await expect(wikilink).toBeVisible()
const box = await wikilink.boundingBox()
expect(box).not.toBeNull()
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
await page.waitForTimeout(300)
// New tab should open with the target note active
await expect(page.locator('.editor__tab--active')).toHaveText(/Matteo Cellini/)
// Editor should show the target note's content
await expect(page.locator('.cm-content')).toContainText('Matteo Cellini')
await page.screenshot({ path: 'test-results/core-wikilink-nav.png', fullPage: true })
})

View File

@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'coverage']),
globalIgnores(['dist', 'coverage', 'src-tauri/resources/', 'src-tauri/target/']),
{
files: ['**/*.{ts,tsx}'],
extends: [

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
"lint": "eslint .",
"preview": "vite preview",
"tauri": "tauri",
@@ -64,6 +65,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"esbuild": "^0.27.3",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",

708
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,5 @@
packages:
- mcp-server
ignoredBuiltDependencies:
- esbuild

View File

@@ -0,0 +1,45 @@
/**
* Bundle the mcp-server Node.js files into self-contained CJS bundles
* that can be shipped as Tauri resources inside the .app bundle.
*
* Output: src-tauri/resources/mcp-server/{index.js,ws-bridge.js}
*/
import { build } from 'esbuild'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { mkdirSync, writeFileSync } from 'fs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = join(__dirname, '..')
const SRC = join(ROOT, 'mcp-server')
const OUT = join(ROOT, 'src-tauri', 'resources', 'mcp-server')
mkdirSync(OUT, { recursive: true })
// Tell Node.js that this directory contains CJS bundles, even if the
// root package.json declares "type": "module".
writeFileSync(join(OUT, 'package.json'), JSON.stringify({ type: 'commonjs' }))
const shared = {
platform: 'node',
bundle: true,
format: 'cjs',
target: 'node18',
// Mark optional native bindings as external — ws works fine without them
external: ['bufferutil', 'utf-8-validate'],
logLevel: 'warning',
}
await build({
...shared,
entryPoints: [join(SRC, 'index.js')],
outfile: join(OUT, 'index.js'),
})
await build({
...shared,
entryPoints: [join(SRC, 'ws-bridge.js')],
outfile: join(OUT, 'ws-bridge.js'),
})
console.log('mcp-server bundled → src-tauri/resources/mcp-server/')

View File

@@ -461,6 +461,61 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
.collect()
}
/// Resolve a single conflict file by choosing "ours" or "theirs" strategy,
/// then stage the result.
pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let checkout_flag = match strategy {
"ours" => "--ours",
"theirs" => "--theirs",
_ => {
return Err(format!(
"Invalid strategy '{}': must be 'ours' or 'theirs'",
strategy
))
}
};
run_git(vault, &["checkout", checkout_flag, "--", file])?;
run_git(vault, &["add", "--", file])?;
Ok(())
}
/// Commit after all merge conflicts have been resolved.
pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Verify no remaining conflicts
let remaining = get_conflict_files(vault_path)?;
if !remaining.is_empty() {
return Err(format!(
"Cannot commit: {} file(s) still have unresolved conflicts",
remaining.len()
));
}
let commit = Command::new("git")
.args(["commit", "-m", "Resolve merge conflicts"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?;
if !commit.status.success() {
let stderr = String::from_utf8_lossy(&commit.stderr);
let stdout = String::from_utf8_lossy(&commit.stdout);
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
return Err(format!("git commit failed: {}", detail.trim()));
}
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
}
/// Push to remote.
pub fn git_push(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
@@ -1217,6 +1272,113 @@ mod tests {
);
}
/// Set up a pair of clones that have a merge conflict on the same file.
/// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict.
fn setup_conflict_pair() -> (TempDir, TempDir, TempDir) {
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
let vp_a = clone_a_dir.path().to_str().unwrap();
let vp_b = clone_b_dir.path().to_str().unwrap();
// A creates the file and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
git_commit(vp_a, "create conflict.md").unwrap();
git_push(vp_a).unwrap();
// B pulls to get the file
git_pull(vp_b).unwrap();
// A modifies and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
git_commit(vp_a, "A's change").unwrap();
git_push(vp_a).unwrap();
// B modifies the same file locally and commits
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
// B pulls — this causes a merge conflict
let result = git_pull(vp_b).unwrap();
assert_eq!(result.status, "conflict");
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_resolve_conflict_ours() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(conflicts.contains(&"conflict.md".to_string()));
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version B\n");
}
#[test]
fn test_resolve_conflict_theirs() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version A\n");
}
#[test]
fn test_resolve_conflict_invalid_strategy() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let result = git_resolve_conflict(vp_b, "conflict.md", "invalid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid strategy"));
}
#[test]
fn test_commit_conflict_resolution() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
// Resolve all conflicts first
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
// Verify the merge commit exists
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(clone_b.path())
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Resolve merge conflicts"));
}
#[test]
fn test_commit_conflict_resolution_fails_with_unresolved() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
// Don't resolve — try to commit directly
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("still have unresolved conflicts"));
}
#[test]
fn test_parse_github_repo_path_non_github() {
assert_eq!(

486
src-tauri/src/indexing.rs Normal file
View File

@@ -0,0 +1,486 @@
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use std::sync::Mutex;
static QMD_PATH_CACHE: Mutex<Option<String>> = Mutex::new(None);
/// Locate the qmd binary, checking known locations and PATH.
/// Caches the result for subsequent calls.
pub fn find_qmd_binary() -> Option<String> {
if let Ok(guard) = QMD_PATH_CACHE.lock() {
if let Some(ref cached) = *guard {
return Some(cached.clone());
}
}
let result = find_qmd_binary_uncached();
if let Some(ref path) = result {
if let Ok(mut guard) = QMD_PATH_CACHE.lock() {
*guard = Some(path.clone());
}
}
result
}
fn find_qmd_binary_uncached() -> Option<String> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/qmd").to_string_lossy().to_string()),
Some("/usr/local/bin/qmd".to_string()),
Some("/opt/homebrew/bin/qmd".to_string()),
];
for candidate in candidates.into_iter().flatten() {
if Path::new(&candidate).exists() {
return Some(candidate);
}
}
// Fallback: try PATH
Command::new("which")
.arg("qmd")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
/// Clear the cached qmd path (e.g. after auto-install).
pub fn clear_qmd_cache() {
if let Ok(mut guard) = QMD_PATH_CACHE.lock() {
*guard = None;
}
}
#[derive(Debug, Serialize, Clone)]
pub struct IndexStatus {
pub available: bool,
pub qmd_installed: bool,
pub collection_exists: bool,
pub indexed_count: usize,
pub embedded_count: usize,
pub pending_embed: usize,
}
/// Check whether the vault has a qmd index and its status.
pub fn check_index_status(vault_path: &str) -> IndexStatus {
let qmd_bin = match find_qmd_binary() {
Some(b) => b,
None => {
return IndexStatus {
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
}
}
};
let vault_name = vault_dir_name(vault_path);
let output = Command::new(&qmd_bin).args(["status"]).output();
match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
parse_status_for_vault(&stdout, &vault_name)
}
_ => IndexStatus {
available: false,
qmd_installed: true,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
},
}
}
fn vault_dir_name(vault_path: &str) -> String {
Path::new(vault_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("laputa")
.to_lowercase()
}
fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus {
let mut collection_exists = false;
let mut indexed_count = 0;
let mut embedded_count = 0;
let mut pending_embed = 0;
// Look for collection section matching vault name
let mut in_vault_section = false;
for line in status_output.lines() {
let trimmed = line.trim();
// Collection headers look like: " laputa (qmd://laputa/)"
if trimmed.contains(&format!("qmd://{vault_name}/")) {
collection_exists = true;
in_vault_section = true;
continue;
}
// New collection section starts
if trimmed.contains("qmd://") && !trimmed.contains(vault_name) {
in_vault_section = false;
continue;
}
if in_vault_section {
if let Some(count_str) = extract_count_from_line(trimmed, "Files:") {
indexed_count = count_str;
}
}
}
// Global counts from the Documents section
for line in status_output.lines() {
let trimmed = line.trim();
if trimmed.starts_with("Total:") {
if let Some(n) = extract_first_number(trimmed) {
if embedded_count == 0 && indexed_count == 0 {
indexed_count = n;
}
}
} else if trimmed.starts_with("Vectors:") {
if let Some(n) = extract_first_number(trimmed) {
embedded_count = n;
}
} else if trimmed.starts_with("Pending:") {
if let Some(n) = extract_first_number(trimmed) {
pending_embed = n;
}
}
}
IndexStatus {
available: true,
qmd_installed: true,
collection_exists,
indexed_count,
embedded_count,
pending_embed,
}
}
fn extract_count_from_line(line: &str, prefix: &str) -> Option<usize> {
if !line.starts_with(prefix) {
return None;
}
extract_first_number(line)
}
fn extract_first_number(s: &str) -> Option<usize> {
s.split_whitespace()
.find_map(|word| word.parse::<usize>().ok())
}
/// Ensure a qmd collection exists for this vault. Creates one if missing.
pub fn ensure_collection(vault_path: &str) -> Result<(), String> {
let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?;
let vault_name = vault_dir_name(vault_path);
// Check if collection already exists
let output = Command::new(&qmd_bin)
.args(["collection", "list"])
.output()
.map_err(|e| format!("Failed to list collections: {e}"))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains(&format!("qmd://{vault_name}/")) {
return Ok(());
}
}
// Create collection
Command::new(&qmd_bin)
.args([
"collection",
"add",
vault_path,
"--name",
&vault_name,
"--mask",
"**/*.md",
])
.output()
.map_err(|e| format!("Failed to create collection: {e}"))?;
Ok(())
}
#[derive(Debug, Serialize, Clone)]
pub struct IndexingProgress {
pub phase: String,
pub current: usize,
pub total: usize,
pub done: bool,
pub error: Option<String>,
}
/// Run full indexing: update + embed. Returns progress updates via callback.
pub fn run_full_index<F>(vault_path: &str, on_progress: F) -> Result<(), String>
where
F: Fn(IndexingProgress),
{
let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?;
ensure_collection(vault_path)?;
// Phase 1: update (scan files)
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: 0,
total: 0,
done: false,
error: None,
});
let update_output = Command::new(&qmd_bin)
.args(["update"])
.output()
.map_err(|e| format!("qmd update failed: {e}"))?;
if !update_output.status.success() {
let stderr = String::from_utf8_lossy(&update_output.stderr);
let err = format!("qmd update failed: {stderr}");
on_progress(IndexingProgress {
phase: "error".to_string(),
current: 0,
total: 0,
done: true,
error: Some(err.clone()),
});
return Err(err);
}
// Parse update output for counts
let update_stdout = String::from_utf8_lossy(&update_output.stdout);
let total = parse_indexed_count(&update_stdout);
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: total,
total,
done: false,
error: None,
});
// Phase 2: embed (generate vectors)
on_progress(IndexingProgress {
phase: "embedding".to_string(),
current: 0,
total,
done: false,
error: None,
});
let embed_output = Command::new(&qmd_bin)
.args(["embed"])
.output()
.map_err(|e| format!("qmd embed failed: {e}"))?;
if !embed_output.status.success() {
let stderr = String::from_utf8_lossy(&embed_output.stderr);
// Embedding failure is non-fatal — keyword search still works
log::warn!("qmd embed failed (keyword search still works): {stderr}");
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
total,
done: true,
error: Some("Embedding failed — keyword search only".to_string()),
});
return Ok(());
}
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
total,
done: true,
error: None,
});
Ok(())
}
fn parse_indexed_count(update_output: &str) -> usize {
// qmd update output typically contains lines like "Indexed 9078 files"
for line in update_output.lines() {
if let Some(n) = extract_first_number(line) {
return n;
}
}
0
}
/// Run incremental update for a single file change.
pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?;
// Verify collection exists
let vault_name = vault_dir_name(vault_path);
let list_output = Command::new(&qmd_bin)
.args(["collection", "list"])
.output()
.map_err(|e| format!("Failed to list collections: {e}"))?;
if list_output.status.success() {
let stdout = String::from_utf8_lossy(&list_output.stdout);
if !stdout.contains(&format!("qmd://{vault_name}/")) {
// Collection doesn't exist yet — skip incremental, full index needed
return Ok(());
}
}
let output = Command::new(&qmd_bin)
.args(["update"])
.output()
.map_err(|e| format!("qmd incremental update failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("qmd update failed: {stderr}"));
}
Ok(())
}
/// Attempt to auto-install qmd via bun. Returns Ok if successful.
pub fn auto_install_qmd() -> Result<String, String> {
// Find bun
let bun = find_bun().ok_or("bun not installed — cannot auto-install qmd")?;
let output = Command::new(&bun)
.args(["install", "-g", "qmd"])
.output()
.map_err(|e| format!("Failed to install qmd: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("qmd installation failed: {stderr}"));
}
// Clear cache so find_qmd_binary() re-discovers
clear_qmd_cache();
match find_qmd_binary() {
Some(path) => Ok(path),
None => Err("qmd installed but binary not found".to_string()),
}
}
fn find_bun() -> Option<String> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/bun").to_string_lossy().to_string()),
Some("/opt/homebrew/bin/bun".to_string()),
Some("/usr/local/bin/bun".to_string()),
];
for candidate in candidates.into_iter().flatten() {
if Path::new(&candidate).exists() {
return Some(candidate);
}
}
Command::new("which")
.arg("bun")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vault_dir_name_extracts_last_segment() {
assert_eq!(vault_dir_name("/Users/luca/Laputa"), "laputa");
assert_eq!(vault_dir_name("/home/user/MyVault"), "myvault");
}
#[test]
fn vault_dir_name_fallback() {
assert_eq!(vault_dir_name(""), "laputa");
}
#[test]
fn extract_first_number_works() {
assert_eq!(
extract_first_number("Total: 9078 files indexed"),
Some(9078)
);
assert_eq!(extract_first_number("Vectors: 14676 embedded"), Some(14676));
assert_eq!(extract_first_number("no numbers here"), None);
}
#[test]
fn parse_status_finds_collection() {
let status = r#"
QMD Status
Index: /Users/luca/.cache/qmd/index.sqlite
Size: 100.9 MB
Documents
Total: 9115 files indexed
Vectors: 14676 embedded
Pending: 26 need embedding
Collections
laputa (qmd://laputa/)
Pattern: **/*.md
Files: 9078 (updated 20d ago)
"#;
let result = parse_status_for_vault(status, "laputa");
assert!(result.collection_exists);
assert_eq!(result.indexed_count, 9078);
assert_eq!(result.embedded_count, 14676);
assert_eq!(result.pending_embed, 26);
}
#[test]
fn parse_status_missing_collection() {
let status = r#"
QMD Status
Documents
Total: 100 files indexed
Vectors: 50 embedded
Pending: 0
Collections
other (qmd://other/)
Files: 100
"#;
let result = parse_status_for_vault(status, "laputa");
assert!(!result.collection_exists);
}
#[test]
fn extract_count_from_line_works() {
assert_eq!(
extract_count_from_line("Files: 9078 (updated 20d ago)", "Files:"),
Some(9078)
);
assert_eq!(extract_count_from_line("Pattern: **/*.md", "Files:"), None);
}
#[test]
fn parse_indexed_count_from_output() {
assert_eq!(parse_indexed_count("Indexed 342 files in 1.2s"), 342);
assert_eq!(parse_indexed_count("No output"), 0);
}
}

View File

@@ -3,12 +3,14 @@ pub mod claude_cli;
pub mod frontmatter;
pub mod git;
pub mod github;
pub mod indexing;
pub mod mcp;
pub mod menu;
pub mod search;
pub mod settings;
pub mod theme;
pub mod vault;
pub mod vault_list;
use std::borrow::Cow;
use std::path::Path;
@@ -20,10 +22,12 @@ use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeS
use frontmatter::FrontmatterValue;
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile};
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use indexing::{IndexStatus, IndexingProgress};
use search::SearchResponse;
use settings::Settings;
use theme::{ThemeFile, VaultSettings};
use vault::{RenameResult, VaultEntry};
use vault_list::VaultList;
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
@@ -114,10 +118,7 @@ fn git_commit(vault_path: String, message: String) -> Result<String, String> {
#[tauri::command]
fn get_build_number() -> String {
{
let n = std::env::var("BUILD_NUMBER").unwrap_or_else(|_| "0".to_string());
format!("b{}", n)
}
format!("b{}", env!("BUILD_NUMBER"))
}
#[tauri::command]
@@ -132,6 +133,18 @@ fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
git::git_pull(&vault_path)
}
#[tauri::command]
fn git_resolve_conflict(vault_path: String, file: String, strategy: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
git::git_resolve_conflict(&vault_path, &file, &strategy)
}
#[tauri::command]
fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit_conflict_resolution(&vault_path)
}
#[tauri::command]
fn git_push(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
@@ -207,6 +220,12 @@ fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
vault::purge_trash(&vault_path)
}
#[tauri::command]
fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
@@ -277,6 +296,16 @@ fn save_settings(settings: Settings) -> Result<(), String> {
settings::save_settings(settings)
}
#[tauri::command]
fn load_vault_list() -> Result<VaultList, String> {
vault_list::load_vault_list()
}
#[tauri::command]
fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[tauri::command]
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
github::github_list_repos(&token).await
@@ -326,6 +355,64 @@ async fn search_vault(
.map_err(|e| format!("Search task failed: {}", e))?
}
#[tauri::command]
fn get_index_status(vault_path: String) -> IndexStatus {
let vault_path = expand_tilde(&vault_path);
indexing::check_index_status(&vault_path)
}
#[tauri::command]
async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Result<(), String> {
use tauri::Emitter;
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
// Auto-install qmd if not available
if indexing::find_qmd_binary().is_none() {
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "installing".to_string(),
current: 0,
total: 0,
done: false,
error: None,
},
);
match indexing::auto_install_qmd() {
Ok(_) => log::info!("qmd auto-installed successfully"),
Err(e) => {
log::warn!("qmd auto-install failed: {e}");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "error".to_string(),
current: 0,
total: 0,
done: true,
error: Some(format!("qmd not available: {e}")),
},
);
return Err(e);
}
}
}
indexing::run_full_index(&vault_path, |progress| {
let _ = app_handle.emit("indexing-progress", &progress);
})
})
.await
.map_err(|e| format!("Indexing task failed: {e}"))?
}
#[tauri::command]
async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
.await
.map_err(|e| format!("Incremental index failed: {e}"))?
}
struct WsBridgeChild(Mutex<Option<Child>>);
#[tauri::command]
@@ -361,9 +448,9 @@ fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<()
}
#[tauri::command]
fn set_active_theme(vault_path: String, theme_id: String) -> Result<(), String> {
fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::set_active_theme(&vault_path, &theme_id)
theme::set_active_theme(&vault_path, theme_id.as_deref())
}
#[tauri::command]
@@ -372,6 +459,12 @@ fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String,
theme::create_theme(&vault_path, source_id.as_deref())
}
#[tauri::command]
fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_vault_theme(&vault_path, name.as_deref())
}
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
@@ -398,8 +491,10 @@ fn run_startup_tasks() {
vault::migrate_is_a_to_type(vp_str),
);
// Seed _themes/ with built-in themes if missing
// Seed _themes/ with built-in JSON themes (legacy) if missing
theme::seed_default_themes(vp_str);
// Seed theme/ with built-in vault theme notes if missing
theme::seed_vault_themes(vp_str);
// Register Laputa MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
@@ -452,6 +547,22 @@ mod tests {
"expected 'b' prefix, got: {}",
result
);
assert_ne!(result, "b0", "build number should not fall back to 0");
}
}
fn spawn_ws_bridge(app: &mut tauri::App) {
use tauri::Manager;
let vault_path = dirs::home_dir()
.map(|h| h.join("Laputa"))
.unwrap_or_default();
let vp_str = vault_path.to_string_lossy().to_string();
match mcp::spawn_ws_bridge(&vp_str) {
Ok(child) => {
let state: tauri::State<'_, WsBridgeChild> = app.state();
*state.0.lock().unwrap() = Some(child);
}
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
}
}
@@ -485,23 +596,7 @@ pub fn run() {
}
run_startup_tasks();
// Spawn the MCP WebSocket bridge for the default vault
{
use tauri::Manager;
let vault_path = dirs::home_dir()
.map(|h| h.join("Laputa"))
.unwrap_or_default();
let vp_str = vault_path.to_string_lossy().to_string();
match mcp::spawn_ws_bridge(&vp_str) {
Ok(child) => {
let state: tauri::State<'_, WsBridgeChild> = app.state();
*state.0.lock().unwrap() = Some(child);
}
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
}
}
spawn_ws_bridge(app);
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -520,6 +615,8 @@ pub fn run() {
get_last_commit_info,
git_pull,
git_push,
git_resolve_conflict,
git_commit_conflict_resolution,
ai_chat,
check_claude_cli,
stream_claude_chat,
@@ -527,12 +624,15 @@ pub fn run() {
save_image,
copy_image_to_vault,
purge_trash,
delete_note,
migrate_is_a_to_type,
batch_archive_notes,
batch_trash_notes,
get_settings,
update_menu_state,
save_settings,
load_vault_list,
save_vault_list,
github_list_repos,
github_create_repo,
clone_repo,
@@ -540,6 +640,9 @@ pub fn run() {
github_device_flow_poll,
github_get_user,
search_vault,
get_index_status,
start_indexing,
trigger_incremental_index,
create_getting_started_vault,
check_vault_exists,
get_default_vault_path,
@@ -549,7 +652,8 @@ pub fn run() {
get_vault_settings,
save_vault_settings,
set_active_theme,
create_theme
create_theme,
create_vault_theme
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View File

@@ -27,10 +27,12 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
}
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
// On macOS the exe lives at Contents/MacOS/<binary>.
// Resources are placed at Contents/Resources/ by Tauri.
let release_path = exe
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("mcp-server"))
.map(|p| p.join("Resources").join("mcp-server"))
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
if release_path.join("ws-bridge.js").exists() {
return Ok(release_path);

View File

@@ -1,3 +1,4 @@
use crate::indexing;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
@@ -30,31 +31,6 @@ struct QmdResult {
pub score: f64,
}
fn find_qmd_binary() -> Option<String> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/qmd").to_string_lossy().to_string()),
Some("/usr/local/bin/qmd".to_string()),
Some("/opt/homebrew/bin/qmd".to_string()),
];
for candidate in candidates.into_iter().flatten() {
if Path::new(&candidate).exists() {
return Some(candidate);
}
}
// Fallback: try PATH
Command::new("which")
.arg("qmd")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
fn qmd_uri_to_vault_path(uri: &str, vault_path: &str) -> String {
// qmd://laputa/essay/foo.md → essay/foo.md
let relative = uri
@@ -108,7 +84,7 @@ fn detect_collection_name(vault_path: &str) -> String {
}
fn detect_collection_name_uncached(vault_path: &str) -> String {
let qmd_bin = match find_qmd_binary() {
let qmd_bin = match indexing::find_qmd_binary() {
Some(b) => b,
None => return "laputa".to_string(),
};
@@ -149,8 +125,8 @@ pub fn search_vault(
) -> Result<SearchResponse, String> {
let start = Instant::now();
let qmd_bin =
find_qmd_binary().ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?;
let qmd_bin = indexing::find_qmd_binary()
.ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?;
let collection = detect_collection_name(vault_path);

View File

@@ -71,6 +71,36 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
save_settings_at(&settings_path()?, settings)
}
fn last_vault_file() -> Result<PathBuf, String> {
dirs::config_dir()
.map(|d| d.join("com.laputa.app").join("last-vault.txt"))
.ok_or_else(|| "Could not determine config directory".to_string())
}
fn get_last_vault_at(path: &PathBuf) -> Option<String> {
fs::read_to_string(path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn set_last_vault_at(path: &PathBuf, vault_path: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
fs::write(path, vault_path.trim())
.map_err(|e| format!("Failed to write last vault path: {}", e))
}
pub fn get_last_vault() -> Option<String> {
last_vault_file().ok().and_then(|p| get_last_vault_at(&p))
}
pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
set_last_vault_at(&last_vault_file()?, vault_path)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -199,4 +229,65 @@ mod tests {
assert!(result.is_ok());
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
}
#[test]
fn test_get_last_vault_returns_none_for_missing_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("last-vault.txt");
assert!(get_last_vault_at(&path).is_none());
}
#[test]
fn test_set_and_get_last_vault_roundtrip() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("last-vault.txt");
set_last_vault_at(&path, "/Users/test/MyVault").unwrap();
assert_eq!(
get_last_vault_at(&path).as_deref(),
Some("/Users/test/MyVault")
);
}
#[test]
fn test_set_last_vault_trims_whitespace() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("last-vault.txt");
set_last_vault_at(&path, " /Users/test/Vault ").unwrap();
assert_eq!(
get_last_vault_at(&path).as_deref(),
Some("/Users/test/Vault")
);
}
#[test]
fn test_get_last_vault_returns_none_for_empty_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("last-vault.txt");
fs::write(&path, " \n ").unwrap();
assert!(get_last_vault_at(&path).is_none());
}
#[test]
fn test_set_last_vault_creates_parent_directories() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested").join("dir").join("last-vault.txt");
set_last_vault_at(&path, "/Users/test/Vault").unwrap();
assert!(path.exists());
assert_eq!(
get_last_vault_at(&path).as_deref(),
Some("/Users/test/Vault")
);
}
#[test]
fn test_set_last_vault_overwrites_previous() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("last-vault.txt");
set_last_vault_at(&path, "/Users/test/OldVault").unwrap();
set_last_vault_at(&path, "/Users/test/NewVault").unwrap();
assert_eq!(
get_last_vault_at(&path).as_deref(),
Some("/Users/test/NewVault")
);
}
}

View File

@@ -98,10 +98,10 @@ pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<
.map_err(|e| format!("Failed to write vault settings: {e}"))
}
/// Set the active theme in vault settings.
pub fn set_active_theme(vault_path: &str, theme_id: &str) -> Result<(), String> {
/// Set the active theme in vault settings. Pass `None` to clear.
pub fn set_active_theme(vault_path: &str, theme_id: Option<&str>) -> Result<(), String> {
let mut settings = get_vault_settings(vault_path)?;
settings.theme = Some(theme_id.to_string());
settings.theme = theme_id.map(|s| s.to_string());
save_vault_settings(vault_path, settings)
}
@@ -116,20 +116,112 @@ pub fn get_theme(vault_path: &str, theme_id: &str) -> Result<ThemeFile, String>
parse_theme_file(&path)
}
/// Create `dir` and write each `(filename, content)` pair if the directory doesn't exist yet.
fn seed_dir_with_files(dir: &Path, files: &[(&str, &str)], log_msg: &str) {
if dir.is_dir() {
return;
}
if fs::create_dir_all(dir).is_err() {
return;
}
for (name, content) in files {
let _ = fs::write(dir.join(name), content);
}
log::info!("{log_msg}");
}
/// Seed the `_themes/` directory with built-in themes if it doesn't exist yet.
/// Safe to call multiple times — only writes files that are missing.
pub fn seed_default_themes(vault_path: &str) {
let themes_dir = Path::new(vault_path).join("_themes");
if themes_dir.is_dir() {
return;
seed_dir_with_files(
&Path::new(vault_path).join("_themes"),
&[
("default.json", DEFAULT_THEME),
("dark.json", DARK_THEME),
("minimal.json", MINIMAL_THEME),
],
"Seeded _themes/ with built-in themes",
);
}
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
/// Safe to call multiple times — only writes files that are missing.
pub fn seed_vault_themes(vault_path: &str) {
seed_dir_with_files(
&Path::new(vault_path).join("theme"),
&[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
("minimal.md", MINIMAL_VAULT_THEME),
],
"Seeded theme/ with built-in vault themes",
);
}
/// Create a new vault theme note in `theme/` directory.
/// Returns the absolute path to the newly created theme note.
pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result<String, String> {
let theme_dir = Path::new(vault_path).join("theme");
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
let display_name = name.unwrap_or("Untitled Theme");
let slug = slugify(display_name);
let filename = format!("{}.md", find_available_stem(&theme_dir, &slug, "md"));
let path = theme_dir.join(&filename);
let content = vault_theme_note_content(display_name, &DEFAULT_VAULT_THEME_VARS);
fs::write(&path, content).map_err(|e| format!("Failed to write theme note: {e}"))?;
Ok(path.to_string_lossy().to_string())
}
/// Convert a display name to a URL-safe slug.
fn slugify(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Find an available filename stem (base, base-2, base-3, …) that doesn't conflict when `ext` is appended.
fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String {
if !dir.join(format!("{base}.{ext}")).exists() {
return base.to_string();
}
if fs::create_dir_all(&themes_dir).is_err() {
return;
for i in 2.. {
let candidate = format!("{base}-{i}");
if !dir.join(format!("{candidate}.{ext}")).exists() {
return candidate;
}
}
let _ = fs::write(themes_dir.join("default.json"), DEFAULT_THEME);
let _ = fs::write(themes_dir.join("dark.json"), DARK_THEME);
let _ = fs::write(themes_dir.join("minimal.json"), MINIMAL_THEME);
log::info!("Seeded _themes/ with built-in themes");
unreachable!()
}
/// Build a vault theme note markdown string from a name and CSS variable map.
fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String {
let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n");
for (key, value) in vars {
// Values with '#' or spaces need quoting; others can be bare strings.
if value.contains('#') || value.contains('\'') || value.contains(',') {
fm.push_str(&format!("{key}: \"{value}\"\n"));
} else {
fm.push_str(&format!("{key}: {value}\n"));
}
}
fm.push_str("---\n\n");
fm.push_str(&format!(
"# {name} Theme\n\nA custom {name} theme for Laputa.\n"
));
fm
}
/// Create a new theme file by copying the active theme (or default).
@@ -139,7 +231,7 @@ pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String,
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
let new_id = find_available_id(&themes_dir, "untitled");
let new_id = find_available_stem(&themes_dir, "untitled", "json");
let source = source_id.unwrap_or("default");
let source_path = themes_dir.join(format!("{source}.json"));
@@ -169,20 +261,6 @@ pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String,
Ok(new_id)
}
/// Find a filename that doesn't conflict (untitled, untitled-2, untitled-3, ...).
fn find_available_id(dir: &Path, base: &str) -> String {
if !dir.join(format!("{base}.json")).exists() {
return base.to_string();
}
for i in 2.. {
let candidate = format!("{base}-{i}");
if !dir.join(format!("{candidate}.json")).exists() {
return candidate;
}
}
unreachable!()
}
/// Generate the default light theme JSON.
fn default_theme_json(name: &str) -> String {
serde_json::to_string_pretty(&serde_json::json!({
@@ -312,6 +390,233 @@ pub const MINIMAL_THEME: &str = r##"{
}
}"##;
/// CSS variable key-value pairs for the default light vault theme.
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
// shadcn/ui base
("background", "#FFFFFF"),
("foreground", "#37352F"),
("card", "#FFFFFF"),
("popover", "#FFFFFF"),
("primary", "#155DFF"),
("primary-foreground", "#FFFFFF"),
("secondary", "#EBEBEA"),
("secondary-foreground", "#37352F"),
("muted", "#F0F0EF"),
("muted-foreground", "#787774"),
("accent", "#EBEBEA"),
("accent-foreground", "#37352F"),
("destructive", "#E03E3E"),
("border", "#E9E9E7"),
("input", "#E9E9E7"),
("ring", "#155DFF"),
("sidebar", "#F7F6F3"),
("sidebar-foreground", "#37352F"),
("sidebar-border", "#E9E9E7"),
("sidebar-accent", "#EBEBEA"),
// Text hierarchy
("text-primary", "#37352F"),
("text-secondary", "#787774"),
("text-muted", "#B4B4B4"),
("text-heading", "#37352F"),
// Backgrounds
("bg-primary", "#FFFFFF"),
("bg-sidebar", "#F7F6F3"),
("bg-hover", "#EBEBEA"),
("bg-hover-subtle", "#F0F0EF"),
("bg-selected", "#E8F4FE"),
("border-primary", "#E9E9E7"),
// Accent colours
("accent-blue", "#155DFF"),
("accent-green", "#00B38B"),
("accent-orange", "#D9730D"),
("accent-red", "#E03E3E"),
("accent-purple", "#A932FF"),
("accent-yellow", "#F0B100"),
("accent-blue-light", "#155DFF14"),
("accent-green-light", "#00B38B14"),
("accent-purple-light", "#A932FF14"),
("accent-red-light", "#E03E3E14"),
("accent-yellow-light", "#F0B10014"),
// Typography
(
"font-family",
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
),
("font-size-base", "14px"),
// Editor
("editor-font-size", "16"),
("editor-line-height", "1.5"),
("editor-max-width", "720"),
];
/// Vault-based theme note for the built-in Default theme.
pub const DEFAULT_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: Light theme with warm, paper-like tones\n\
background: \"#FFFFFF\"\n\
foreground: \"#37352F\"\n\
card: \"#FFFFFF\"\n\
popover: \"#FFFFFF\"\n\
primary: \"#155DFF\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#EBEBEA\"\n\
secondary-foreground: \"#37352F\"\n\
muted: \"#F0F0EF\"\n\
muted-foreground: \"#787774\"\n\
accent: \"#EBEBEA\"\n\
accent-foreground: \"#37352F\"\n\
destructive: \"#E03E3E\"\n\
border: \"#E9E9E7\"\n\
input: \"#E9E9E7\"\n\
ring: \"#155DFF\"\n\
sidebar: \"#F7F6F3\"\n\
sidebar-foreground: \"#37352F\"\n\
sidebar-border: \"#E9E9E7\"\n\
sidebar-accent: \"#EBEBEA\"\n\
text-primary: \"#37352F\"\n\
text-secondary: \"#787774\"\n\
text-muted: \"#B4B4B4\"\n\
text-heading: \"#37352F\"\n\
bg-primary: \"#FFFFFF\"\n\
bg-sidebar: \"#F7F6F3\"\n\
bg-hover: \"#EBEBEA\"\n\
bg-hover-subtle: \"#F0F0EF\"\n\
bg-selected: \"#E8F4FE\"\n\
border-primary: \"#E9E9E7\"\n\
accent-blue: \"#155DFF\"\n\
accent-green: \"#00B38B\"\n\
accent-orange: \"#D9730D\"\n\
accent-red: \"#E03E3E\"\n\
accent-purple: \"#A932FF\"\n\
accent-yellow: \"#F0B100\"\n\
accent-blue-light: \"#155DFF14\"\n\
accent-green-light: \"#00B38B14\"\n\
accent-purple-light: \"#A932FF14\"\n\
accent-red-light: \"#E03E3E14\"\n\
accent-yellow-light: \"#F0B10014\"\n\
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
font-size-base: 14px\n\
editor-font-size: 16\n\
editor-line-height: 1.5\n\
editor-max-width: 720\n\
---\n\
\n\
# Default Theme\n\
\n\
The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
/// Vault-based theme note for the built-in Dark theme.
pub const DARK_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: Dark variant with deep navy tones\n\
background: \"#0f0f1a\"\n\
foreground: \"#e0e0e0\"\n\
card: \"#16162a\"\n\
popover: \"#1e1e3a\"\n\
primary: \"#155DFF\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#2a2a4a\"\n\
secondary-foreground: \"#e0e0e0\"\n\
muted: \"#1e1e3a\"\n\
muted-foreground: \"#888888\"\n\
accent: \"#2a2a4a\"\n\
accent-foreground: \"#e0e0e0\"\n\
destructive: \"#f44336\"\n\
border: \"#2a2a4a\"\n\
input: \"#2a2a4a\"\n\
ring: \"#155DFF\"\n\
sidebar: \"#1a1a2e\"\n\
sidebar-foreground: \"#e0e0e0\"\n\
sidebar-border: \"#2a2a4a\"\n\
sidebar-accent: \"#2a2a4a\"\n\
text-primary: \"#e0e0e0\"\n\
text-secondary: \"#888888\"\n\
text-muted: \"#666666\"\n\
text-heading: \"#e0e0e0\"\n\
bg-primary: \"#0f0f1a\"\n\
bg-sidebar: \"#1a1a2e\"\n\
bg-hover: \"#2a2a4a\"\n\
bg-hover-subtle: \"#1e1e3a\"\n\
bg-selected: \"#155DFF22\"\n\
border-primary: \"#2a2a4a\"\n\
accent-blue: \"#155DFF\"\n\
accent-green: \"#00B38B\"\n\
accent-orange: \"#D9730D\"\n\
accent-red: \"#f44336\"\n\
accent-purple: \"#A932FF\"\n\
accent-yellow: \"#F0B100\"\n\
accent-blue-light: \"#155DFF33\"\n\
accent-green-light: \"#00B38B33\"\n\
accent-purple-light: \"#A932FF33\"\n\
accent-red-light: \"#f4433633\"\n\
accent-yellow-light: \"#F0B10033\"\n\
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
font-size-base: 14px\n\
editor-font-size: 16\n\
editor-line-height: 1.5\n\
editor-max-width: 720\n\
---\n\
\n\
# Dark Theme\n\
\n\
A dark theme with deep navy tones for comfortable night-time reading.\n";
/// Vault-based theme note for the built-in Minimal theme.
pub const MINIMAL_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: High contrast, minimal chrome\n\
background: \"#FAFAFA\"\n\
foreground: \"#111111\"\n\
card: \"#FFFFFF\"\n\
popover: \"#FFFFFF\"\n\
primary: \"#000000\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#F0F0F0\"\n\
secondary-foreground: \"#111111\"\n\
muted: \"#F5F5F5\"\n\
muted-foreground: \"#666666\"\n\
accent: \"#F0F0F0\"\n\
accent-foreground: \"#111111\"\n\
destructive: \"#CC0000\"\n\
border: \"#E0E0E0\"\n\
input: \"#E0E0E0\"\n\
ring: \"#000000\"\n\
sidebar: \"#F5F5F5\"\n\
sidebar-foreground: \"#111111\"\n\
sidebar-border: \"#E0E0E0\"\n\
sidebar-accent: \"#E8E8E8\"\n\
text-primary: \"#111111\"\n\
text-secondary: \"#666666\"\n\
text-muted: \"#999999\"\n\
text-heading: \"#111111\"\n\
bg-primary: \"#FAFAFA\"\n\
bg-sidebar: \"#F5F5F5\"\n\
bg-hover: \"#EBEBEB\"\n\
bg-hover-subtle: \"#F5F5F5\"\n\
bg-selected: \"#00000014\"\n\
border-primary: \"#E0E0E0\"\n\
accent-blue: \"#000000\"\n\
accent-green: \"#006600\"\n\
accent-orange: \"#996600\"\n\
accent-red: \"#CC0000\"\n\
accent-purple: \"#660099\"\n\
accent-yellow: \"#996600\"\n\
accent-blue-light: \"#00000014\"\n\
accent-green-light: \"#00660014\"\n\
accent-purple-light: \"#66009914\"\n\
accent-red-light: \"#CC000014\"\n\
accent-yellow-light: \"#99660014\"\n\
font-family: \"'SF Mono', 'Menlo', monospace\"\n\
font-size-base: 13px\n\
editor-font-size: 15\n\
editor-line-height: 1.6\n\
editor-max-width: 680\n\
---\n\
\n\
# Minimal Theme\n\
\n\
High contrast, minimal chrome. Monospace typography throughout.\n";
#[cfg(test)]
mod tests {
use super::*;
@@ -379,9 +684,14 @@ mod tests {
assert!(settings.theme.is_none());
// Set and read back
set_active_theme(vp, "dark").unwrap();
set_active_theme(vp, Some("dark")).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme.as_deref(), Some("dark"));
// Clear theme
set_active_theme(vp, None).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme, None);
}
#[test]
@@ -462,4 +772,88 @@ mod tests {
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2); // broken.json is skipped
}
#[test]
fn test_seed_vault_themes_creates_theme_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
assert!(!vault.join("theme").exists());
seed_vault_themes(vp);
assert!(vault.join("theme").is_dir());
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
assert!(vault.join("theme").join("minimal.md").exists());
}
#[test]
fn test_seed_vault_themes_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
seed_vault_themes(vp); // second call should be a no-op
assert!(vault.join("theme").join("default.md").exists());
}
#[test]
fn test_create_vault_theme_creates_md_file() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, Some("My Theme")).unwrap();
assert!(std::path::Path::new(&path).exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Is A: Theme"));
assert!(content.contains("# My Theme"));
assert!(content.contains("background:"));
}
#[test]
fn test_create_vault_theme_default_name() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, None).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# Untitled Theme"));
}
#[test]
fn test_create_vault_theme_avoids_conflicts() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let p1 = create_vault_theme(vp, Some("Custom")).unwrap();
let p2 = create_vault_theme(vp, Some("Custom")).unwrap();
assert_ne!(p1, p2);
}
#[test]
fn test_slugify() {
assert_eq!(slugify("My Cool Theme"), "my-cool-theme");
assert_eq!(slugify("default"), "default");
assert_eq!(slugify("Dark Mode!"), "dark-mode");
}
#[test]
fn test_vault_theme_content_contains_all_vars() {
let content = DEFAULT_VAULT_THEME;
assert!(content.contains("background:"));
assert!(content.contains("primary:"));
assert!(content.contains("sidebar:"));
assert!(content.contains("text-primary:"));
assert!(content.contains("accent-blue:"));
assert!(content.contains("editor-font-size:"));
}
}

View File

@@ -7,12 +7,16 @@ use super::{parse_md_file, scan_vault, VaultEntry};
// --- Vault Cache ---
/// Bump this when VaultEntry fields change to force a full rescan.
const CACHE_VERSION: u32 = 2;
const CACHE_VERSION: u32 = 4;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {
#[serde(default = "default_cache_version")]
version: u32,
/// The vault path when the cache was written. Used to detect stale caches
/// from a different machine or a moved vault directory.
#[serde(default)]
vault_path: String,
commit_hash: String,
entries: Vec<VaultEntry>,
}
@@ -50,11 +54,6 @@ fn parse_porcelain_line(line: &str) -> Option<(&str, String)> {
Some((&line[..2], line[3..].trim().to_string()))
}
/// Check if a porcelain status indicates a new/untracked file.
fn is_new_file_status(status: &str) -> bool {
status == "??" || status.starts_with('A')
}
/// Extract .md file paths from git diff --name-only output.
fn collect_md_paths_from_diff(stdout: &str) -> Vec<String> {
stdout
@@ -80,11 +79,15 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
.map(|s| collect_md_paths_from_diff(&s))
.unwrap_or_default();
let uncommitted = run_git(vault, &["status", "--porcelain"])
// Use ls-files for untracked files so that newly-seeded directories are picked up
// as individual files rather than as a single "?? dirname/" entry.
let uncommitted = git_uncommitted_files(vault);
// Also include modified-but-unstaged files via status --porcelain.
let modified = run_git(vault, &["status", "--porcelain"])
.map(|s| collect_md_paths_from_porcelain(&s))
.unwrap_or_default();
for path in uncommitted {
for path in uncommitted.into_iter().chain(modified) {
if !files.contains(&path) {
files.push(path);
}
@@ -93,17 +96,10 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
files
}
fn git_uncommitted_new_files(vault: &Path) -> Vec<String> {
let stdout = match run_git(vault, &["status", "--porcelain"]) {
Some(s) => s,
None => return Vec::new(),
};
stdout
.lines()
.filter_map(parse_porcelain_line)
.filter(|(status, path)| path.ends_with(".md") && is_new_file_status(status))
.map(|(_, path)| path)
.collect()
fn git_uncommitted_files(vault: &Path) -> Vec<String> {
run_git(vault, &["status", "--porcelain"])
.map(|s| collect_md_paths_from_porcelain(&s))
.unwrap_or_default()
}
fn load_cache(vault: &Path) -> Option<VaultCache> {
@@ -115,6 +111,7 @@ fn write_cache(vault: &Path, cache: &VaultCache) {
if let Ok(data) = serde_json::to_string(cache) {
let _ = fs::write(cache_path(vault), data);
}
ensure_cache_excluded(vault);
}
/// Normalize an absolute path to a relative path for comparison with git output.
@@ -143,6 +140,23 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
.collect()
}
/// Ensure `.laputa-cache.json` is excluded from git via `.git/info/exclude`.
/// This prevents the cache (which contains machine-specific absolute paths)
/// from being committed and causing stale-path bugs on cloned vaults.
fn ensure_cache_excluded(vault: &Path) {
let exclude_path = vault.join(".git/info/exclude");
let entry = ".laputa-cache.json";
if let Ok(content) = fs::read_to_string(&exclude_path) {
if content.lines().any(|line| line.trim() == entry) {
return;
}
let separator = if content.ends_with('\n') { "" } else { "\n" };
let _ = fs::write(&exclude_path, format!("{content}{separator}{entry}\n"));
} else if exclude_path.parent().map(|p| p.is_dir()).unwrap_or(false) {
let _ = fs::write(&exclude_path, format!("{entry}\n"));
}
}
/// Sort entries by modified_at descending and write the cache.
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
@@ -150,6 +164,7 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
vault,
&VaultCache {
version: CACHE_VERSION,
vault_path: vault.to_string_lossy().to_string(),
commit_hash: hash,
entries: entries.clone(),
},
@@ -157,23 +172,19 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
entries
}
/// Handle same-commit cache hit: add any uncommitted new files.
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
let new_files = git_uncommitted_new_files(vault);
let mut entries = cache.entries;
let existing: std::collections::HashSet<String> = entries
.iter()
.map(|e| to_relative_path(&e.path, vault))
.collect();
let new_entries = parse_files_at(vault, &new_files);
for entry in new_entries {
let rel = to_relative_path(&entry.path, vault);
if !existing.contains(&rel) {
entries.push(entry);
}
let changed = git_uncommitted_files(vault);
if changed.is_empty() {
return cache.entries;
}
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
let mut entries: Vec<VaultEntry> = cache
.entries
.into_iter()
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
.collect();
entries.extend(parse_files_at(vault, &changed));
finalize_and_cache(vault, entries, cache.commit_hash)
}
@@ -212,7 +223,10 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
};
if let Some(cache) = load_cache(vault_path) {
if cache.version != CACHE_VERSION {
let current_vault_str = vault_path.to_string_lossy();
let cache_stale = cache.version != CACHE_VERSION
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref());
if cache_stale {
let entries = scan_vault(vault_path)?;
return Ok(finalize_and_cache(vault_path, entries, current_hash));
}
@@ -300,6 +314,71 @@ mod tests {
assert_eq!(entries2[0].title, "Note");
}
#[test]
fn test_scan_vault_cached_invalidates_stale_vault_path() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
// Init git repo
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "note.md", "# Note\n\nContent.");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// Build cache normally
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0]
.path
.starts_with(&vault.to_string_lossy().as_ref()),
"Entry path should start with vault path"
);
// Tamper with cache to simulate a clone from a different machine
let cache_file = cache_path(vault);
let cache_data = fs::read_to_string(&cache_file).unwrap();
let tampered = cache_data.replace(
&vault.to_string_lossy().as_ref(),
"/Users/other-machine/OtherVault",
);
fs::write(&cache_file, tampered).unwrap();
// Rescanning should invalidate the stale cache and produce correct paths
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1);
assert!(
entries2[0]
.path
.starts_with(&vault.to_string_lossy().as_ref()),
"After stale-cache invalidation, paths should use the current vault path, got: {}",
entries2[0].path
);
}
#[test]
fn test_scan_vault_cached_incremental_different_commit() {
let dir = TempDir::new().unwrap();
@@ -358,4 +437,108 @@ mod tests {
assert!(titles.contains(&"First"));
assert!(titles.contains(&"Second"));
}
#[test]
fn test_update_same_commit_picks_up_modified_file() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
// Commit a type note without sidebar label
create_test_file(vault, "type/news.md", "---\ntype: Type\n---\n# News\n");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// Prime the cache (same commit hash)
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].sidebar_label, None);
// User edits the type note to add sidebar label (uncommitted)
create_test_file(
vault,
"type/news.md",
"---\ntype: Type\nsidebar label: News\n---\n# News\n",
);
// Reload with same git HEAD — must pick up the modification
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1);
assert_eq!(
entries2[0].sidebar_label,
Some("News".to_string()),
"sidebarLabel must reflect the uncommitted edit"
);
}
#[test]
fn test_update_same_commit_new_file_still_added() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "existing.md", "# Existing\n");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// Prime cache
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
// Create new untracked file
create_test_file(vault, "new-note.md", "# New Note\n");
// Cache still same commit — new untracked file must appear
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 2);
let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect();
assert!(titles.contains(&"Existing"));
assert!(titles.contains(&"New Note"));
}
}

View File

@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
/// Default location for the Getting Started vault.
pub fn default_vault_path() -> Result<PathBuf, String> {
dirs::document_dir()
.map(|d| d.join("Laputa"))
.map(|d| d.join("Getting Started"))
.ok_or_else(|| "Could not determine Documents directory".to_string())
}
@@ -423,7 +423,7 @@ mod tests {
let path = default_vault_path().unwrap();
let path_str = path.to_string_lossy();
assert!(path_str.contains("Documents"));
assert!(path_str.ends_with("Laputa"));
assert!(path_str.ends_with("Getting Started"));
}
#[test]

View File

@@ -11,7 +11,7 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{rename_note, RenameResult};
pub use trash::purge_trash;
pub use trash::{delete_note, purge_trash};
use parsing::{
capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet,
@@ -68,6 +68,9 @@ pub struct VaultEntry {
/// Markdown template for notes of this Type. When a new note is created
/// with this type, the template body is pre-filled after the frontmatter.
pub template: Option<String>,
/// Default sort preference for the note list when viewing instances of this Type.
/// Stored as "option:direction" (e.g. "modified:desc", "title:asc", "property:Priority:asc").
pub sort: Option<String>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
@@ -75,6 +78,10 @@ pub struct VaultEntry {
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
#[serde(rename = "outgoingLinks", default)]
pub outgoing_links: Vec<String>,
/// Custom scalar frontmatter properties (non-relationship, non-structural).
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
#[serde(default)]
pub properties: HashMap<String, serde_json::Value>,
}
/// Intermediate struct to capture YAML frontmatter fields.
@@ -114,6 +121,8 @@ struct Frontmatter {
sidebar_label: Option<String>,
#[serde(default)]
template: Option<String>,
#[serde(default)]
sort: Option<String>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -159,6 +168,7 @@ const SKIP_KEYS: &[&str] = &[
"order",
"sidebar label",
"template",
"sort",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -200,6 +210,45 @@ fn extract_relationships(
relationships
}
/// Additional keys to skip when extracting custom properties.
/// These are already first-class fields on VaultEntry, so including them
/// in `properties` would duplicate information.
const PROPERTY_EXTRA_SKIP: &[&str] = &["belongs to", "related to", "owner"];
/// Extract custom scalar properties from raw YAML frontmatter.
/// Captures string, number, and boolean values that are not structural fields
/// and do not contain wikilinks. Arrays and objects are excluded.
fn extract_properties(
data: &HashMap<String, serde_json::Value>,
) -> HashMap<String, serde_json::Value> {
let mut properties = HashMap::new();
for (key, value) in data {
let lower = key.to_ascii_lowercase();
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower))
|| PROPERTY_EXTRA_SKIP
.iter()
.any(|k| k.eq_ignore_ascii_case(&lower))
{
continue;
}
match value {
serde_json::Value::String(s) => {
if !contains_wikilink(s) {
properties.insert(key.clone(), value.clone());
}
}
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => {
properties.insert(key.clone(), value.clone());
}
_ => {}
}
}
properties
}
/// Infer entity type from a parent folder name.
fn infer_type_from_folder(folder: &str) -> String {
match folder {
@@ -243,19 +292,24 @@ fn parse_created_at(fm: &Frontmatter) -> Option<u64> {
.or_else(|| fm.created_time.as_ref().and_then(|s| parse_iso_date(s)))
}
/// Extract frontmatter and relationships from parsed gray_matter data.
/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data.
fn extract_fm_and_rels(
data: Option<gray_matter::Pod>,
) -> (Frontmatter, HashMap<String, Vec<String>>) {
) -> (
Frontmatter,
HashMap<String, Vec<String>>,
HashMap<String, serde_json::Value>,
) {
let hash = match data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return (Frontmatter::default(), HashMap::new()),
_ => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
};
let json_map: HashMap<String, serde_json::Value> =
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
(
parse_frontmatter(&json_map),
extract_relationships(&json_map),
extract_properties(&json_map),
)
}
@@ -282,7 +336,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(&content);
let (frontmatter, mut relationships) = extract_fm_and_rels(parsed.data);
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data);
let title = extract_title(&parsed.content, &filename);
let snippet = extract_snippet(&content);
@@ -339,8 +393,10 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
order: frontmatter.order,
sidebar_label: frontmatter.sidebar_label,
template: frontmatter.template,
sort: frontmatter.sort,
word_count,
outgoing_links,
properties,
})
}
@@ -1134,6 +1190,128 @@ References:
assert!(entry.relationships.get("template").is_none());
}
// --- sort field tests ---
#[test]
fn test_parse_sort_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsort: \"modified:desc\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.sort, Some("modified:desc".to_string()));
}
#[test]
fn test_parse_sort_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.sort, None);
}
#[test]
fn test_sort_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.relationships.get("sort").is_none());
}
#[test]
fn test_sort_not_in_properties() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.properties.get("sort").is_none());
}
// --- custom properties tests ---
#[test]
fn test_extract_properties_scalar_values() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Project
Status: Active
Priority: High
Rating: 5
Due date: 2026-06-15
Reviewed: true
---
# Test
"#;
let entry = parse_test_entry(&dir, "project/test.md", content);
let expected: HashMap<String, serde_json::Value> = [
("Priority".into(), serde_json::Value::String("High".into())),
("Rating".into(), serde_json::json!(5)),
(
"Due date".into(),
serde_json::Value::String("2026-06-15".into()),
),
("Reviewed".into(), serde_json::Value::Bool(true)),
]
.into_iter()
.collect();
assert_eq!(entry.properties, expected);
}
#[test]
fn test_extract_properties_skips_structural_fields() {
let dir = TempDir::new().unwrap();
let content = r#"---
Is A: Project
Status: Active
Owner: Luca
Cadence: Weekly
Archived: false
Priority: High
---
# Test
"#;
let entry = parse_test_entry(&dir, "project/test.md", content);
// Only Priority should survive — all others are structural
assert_eq!(entry.properties.len(), 1);
assert_eq!(
entry.properties.get("Priority").and_then(|v| v.as_str()),
Some("High")
);
}
#[test]
fn test_extract_properties_skips_wikilinks() {
let dir = TempDir::new().unwrap();
let content = r#"---
Mentor: "[[person/alice]]"
Company: Acme Corp
---
# Test
"#;
let entry = parse_test_entry(&dir, "test.md", content);
assert!(entry.properties.get("Mentor").is_none());
assert_eq!(
entry.properties.get("Company").and_then(|v| v.as_str()),
Some("Acme Corp")
);
}
#[test]
fn test_extract_properties_skips_arrays() {
let dir = TempDir::new().unwrap();
let content = r#"---
Tags:
- productivity
- writing
Company: Acme Corp
---
# Test
"#;
let entry = parse_test_entry(&dir, "test.md", content);
assert!(entry.properties.get("Tags").is_none());
assert_eq!(
entry.properties.get("Company").and_then(|v| v.as_str()),
Some("Acme Corp")
);
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -42,6 +42,21 @@ fn try_purge_file(path: &Path) -> Option<String> {
}
}
/// Permanently delete a single note file.
/// Returns the deleted path on success, or an error if the file doesn't exist.
pub fn delete_note(path: &str) -> Result<String, String> {
let file = Path::new(path);
if !file.exists() {
return Err(format!("File does not exist: {}", path));
}
if !file.is_file() {
return Err(format!("Path is not a file: {}", path));
}
fs::remove_file(file).map_err(|e| format!("Failed to delete {}: {}", path, e))?;
log::info!("Permanently deleted note: {}", path);
Ok(path.to_string())
}
/// Scan all markdown files in the vault and delete those where
/// `Trashed at` frontmatter is more than 30 days ago.
/// Returns the list of deleted file paths.
@@ -95,6 +110,28 @@ mod tests {
file.write_all(content.as_bytes()).unwrap();
}
#[test]
fn test_delete_note_removes_file() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"doomed.md",
"---\ntitle: Doomed\n---\n# Doomed\n",
);
let path = dir.path().join("doomed.md");
assert!(path.exists());
let result = delete_note(path.to_str().unwrap());
assert!(result.is_ok());
assert!(!path.exists());
}
#[test]
fn test_delete_note_nonexistent_file() {
let result = delete_note("/nonexistent/path/that/does/not/exist.md");
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_purge_trash_deletes_old_trashed_files() {
let dir = TempDir::new().unwrap();

170
src-tauri/src/vault_list.rs Normal file
View File

@@ -0,0 +1,170 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VaultEntry {
pub label: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VaultList {
pub vaults: Vec<VaultEntry>,
pub active_vault: Option<String>,
#[serde(default)]
pub hidden_defaults: Vec<String>,
}
fn vault_list_path() -> Result<PathBuf, String> {
dirs::config_dir()
.map(|d| d.join("com.laputa.app").join("vaults.json"))
.ok_or_else(|| "Could not determine config directory".to_string())
}
fn load_at(path: &PathBuf) -> Result<VaultList, String> {
if !path.exists() {
return Ok(VaultList::default());
}
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read vault list: {}", e))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault list: {}", e))
}
fn save_at(path: &PathBuf, list: &VaultList) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
let json = serde_json::to_string_pretty(list)
.map_err(|e| format!("Failed to serialize vault list: {}", e))?;
fs::write(path, json).map_err(|e| format!("Failed to write vault list: {}", e))
}
pub fn load_vault_list() -> Result<VaultList, String> {
load_at(&vault_list_path()?)
}
pub fn save_vault_list(list: &VaultList) -> Result<(), String> {
save_at(&vault_list_path()?, list)
}
#[cfg(test)]
mod tests {
use super::*;
fn save_and_reload(list: &VaultList) -> VaultList {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("vaults.json");
save_at(&path, list).unwrap();
load_at(&path).unwrap()
}
#[test]
fn default_vault_list_is_empty() {
let vl = VaultList::default();
assert!(vl.vaults.is_empty());
assert!(vl.active_vault.is_none());
}
#[test]
fn roundtrip_preserves_data() {
let list = VaultList {
vaults: vec![
VaultEntry {
label: "My Vault".to_string(),
path: "/Users/luca/Laputa".to_string(),
},
VaultEntry {
label: "Work".to_string(),
path: "/Users/luca/Work".to_string(),
},
],
active_vault: Some("/Users/luca/Laputa".to_string()),
hidden_defaults: vec![],
};
let loaded = save_and_reload(&list);
assert_eq!(loaded.vaults.len(), 2);
assert_eq!(loaded.vaults[0].label, "My Vault");
assert_eq!(loaded.vaults[0].path, "/Users/luca/Laputa");
assert_eq!(loaded.vaults[1].label, "Work");
assert_eq!(loaded.active_vault.as_deref(), Some("/Users/luca/Laputa"));
}
#[test]
fn load_returns_default_for_missing_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nonexistent.json");
let result = load_at(&path).unwrap();
assert!(result.vaults.is_empty());
assert!(result.active_vault.is_none());
}
#[test]
fn load_returns_error_for_malformed_json() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("bad.json");
fs::write(&path, "not valid json{{{").unwrap();
let err = load_at(&path).unwrap_err();
assert!(err.contains("Failed to parse vault list"));
}
#[test]
fn save_creates_parent_directories() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested").join("dir").join("vaults.json");
let list = VaultList {
vaults: vec![VaultEntry {
label: "Test".to_string(),
path: "/tmp/test".to_string(),
}],
active_vault: None,
hidden_defaults: vec![],
};
save_at(&path, &list).unwrap();
assert!(path.exists());
let loaded = load_at(&path).unwrap();
assert_eq!(loaded.vaults.len(), 1);
}
#[test]
fn vault_list_path_returns_ok() {
let result = vault_list_path();
assert!(result.is_ok());
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
}
#[test]
fn empty_vault_list_roundtrip() {
let list = VaultList::default();
let loaded = save_and_reload(&list);
assert!(loaded.vaults.is_empty());
assert!(loaded.active_vault.is_none());
assert!(loaded.hidden_defaults.is_empty());
}
#[test]
fn hidden_defaults_roundtrip() {
let list = VaultList {
vaults: vec![],
active_vault: None,
hidden_defaults: vec!["/Users/luca/Documents/Getting Started".to_string()],
};
let loaded = save_and_reload(&list);
assert_eq!(loaded.hidden_defaults.len(), 1);
assert_eq!(
loaded.hidden_defaults[0],
"/Users/luca/Documents/Getting Started"
);
}
#[test]
fn load_legacy_format_without_hidden_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("legacy.json");
// Simulate old format without hidden_defaults field
fs::write(&path, r#"{"vaults":[],"active_vault":null}"#).unwrap();
let loaded = load_at(&path).unwrap();
assert!(loaded.hidden_defaults.is_empty());
}
}

View File

@@ -5,9 +5,9 @@
"identifier": "club.refactoring.laputa",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5201",
"devUrl": "http://localhost:5202",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp"
},
"app": {
"withGlobalTauri": true,
@@ -37,6 +37,9 @@
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"resources": {
"resources/mcp-server/**/*": "mcp-server/"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View File

@@ -34,7 +34,7 @@ const mockEntries = [
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,
template: null,
template: null, sort: null,
outgoingLinks: [],
},
{
@@ -51,7 +51,7 @@ const mockEntries = [
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
template: null,
template: null, sort: null,
outgoingLinks: [],
},
]
@@ -71,7 +71,7 @@ const mockCommandResults: Record<string, unknown> = {
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
save_settings: null,
check_vault_exists: true,
get_default_vault_path: '/Users/mock/Documents/Laputa',
get_default_vault_path: '/Users/mock/Documents/Getting Started',
list_themes: [],
get_vault_settings: { theme: null },
}

View File

@@ -17,7 +17,6 @@ import { useMcpRegistration } from './hooks/useMcpRegistration'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { useEditorSave } from './hooks/useEditorSave'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
@@ -28,12 +27,18 @@ import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater } from './hooks/useUpdater'
import { useNavigationHistory } from './hooks/useNavigationHistory'
import { useAutoSync } from './hooks/useAutoSync'
import { useConflictResolver } from './hooks/useConflictResolver'
import { useIndexing } from './hooks/useIndexing'
import { useZoom } from './hooks/useZoom'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useNavigationGestures } from './hooks/useNavigationGestures'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { UpdateBanner } from './components/UpdateBanner'
import { extractOutgoingLinks } from './utils/wikilinks'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection } from './types'
import './App.css'
@@ -75,34 +80,6 @@ function useLayoutPanels() {
}
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function useEditorSaveWithLinks(config: {
updateContent: (path: string, content: string) => void
updateEntry: (path: string, patch: Partial<import('./types').VaultEntry>) => void
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
setToastMessage: (msg: string | null) => void
onAfterSave: () => void
onNotePersisted?: (path: string) => void
}) {
const { updateContent, updateEntry } = config
const saveContent = useCallback((path: string, content: string) => {
updateContent(path, content)
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
}, [updateContent, updateEntry])
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
rawOnChange(path, content)
const links = extractOutgoingLinks(content)
const key = links.join('\0')
if (key !== prevLinksKeyRef.current) {
prevLinksKeyRef.current = key
updateEntry(path, { outgoingLinks: links })
}
}, [rawOnChange, updateEntry])
return { ...editor, handleContentChange }
}
function App() {
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const layout = useLayoutPanels()
@@ -123,7 +100,7 @@ function App() {
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const vault = useVaultLoader(resolvedPath)
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath)
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent)
useMcpRegistration(resolvedPath, setToastMessage)
@@ -133,11 +110,40 @@ function App() {
onVaultUpdated: vault.reloadVault,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names}review needed`)
setToastMessage(`Conflict in ${names}click to resolve`)
},
onToast: (msg) => setToastMessage(msg),
})
// Ref bridges for conflict resolution callbacks (notes declared below)
const openConflictFileRef = useRef<(relativePath: string) => void>(() => {})
const indexing = useIndexing(resolvedPath)
const conflictResolver = useConflictResolver({
vaultPath: resolvedPath,
onResolved: () => {
dialogs.closeConflictResolver()
autoSync.resumePull()
vault.reloadVault()
autoSync.triggerSync()
},
onToast: (msg) => setToastMessage(msg),
onOpenFile: (relativePath) => openConflictFileRef.current(relativePath),
})
const handleOpenConflictResolver = useCallback(() => {
if (autoSync.conflictFiles.length === 0) return
autoSync.pausePull()
conflictResolver.initFiles(autoSync.conflictFiles)
dialogs.openConflictResolver()
}, [autoSync, conflictResolver, dialogs])
const handleCloseConflictResolver = useCallback(() => {
autoSync.resumePull()
dialogs.closeConflictResolver()
}, [autoSync, dialogs])
// Ref bridges handleContentChange (created after notes) into useNoteActions.
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
@@ -183,53 +189,33 @@ function App() {
}
}, [navHistory, isEntryExists, vault.entries, notes])
// Mouse button 3/4 (back/forward) and macOS trackpad two-finger swipe
useEffect(() => {
const handleMouseBack = (e: MouseEvent) => {
if (e.button === 3) { e.preventDefault(); handleGoBack() }
if (e.button === 4) { e.preventDefault(); handleGoForward() }
}
window.addEventListener('mouseup', handleMouseBack)
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
// Trackpad swipe: accumulate horizontal wheel delta and trigger on threshold
let accumulatedDeltaX = 0
let resetTimer: ReturnType<typeof setTimeout> | null = null
const SWIPE_THRESHOLD = 120
const handleWheel = (e: WheelEvent) => {
// Only handle horizontal-dominant gestures (trackpad swipe)
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
if (e.ctrlKey || e.metaKey) return // ignore pinch-zoom
accumulatedDeltaX += e.deltaX
if (resetTimer) clearTimeout(resetTimer)
resetTimer = setTimeout(() => { accumulatedDeltaX = 0 }, 300)
if (accumulatedDeltaX > SWIPE_THRESHOLD) {
accumulatedDeltaX = 0
handleGoForward()
} else if (accumulatedDeltaX < -SWIPE_THRESHOLD) {
accumulatedDeltaX = 0
handleGoBack()
}
}
window.addEventListener('wheel', handleWheel, { passive: true })
return () => {
window.removeEventListener('mouseup', handleMouseBack)
window.removeEventListener('wheel', handleWheel)
if (resetTimer) clearTimeout(resetTimer)
}
}, [handleGoBack, handleGoForward])
const { triggerIncrementalIndex } = indexing
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
triggerIncrementalIndex()
}, [vault, triggerIncrementalIndex])
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
setTabs: notes.setTabs, setToastMessage, onAfterSave: vault.loadModifiedFiles,
setTabs: notes.setTabs, setToastMessage, onAfterSave,
onNotePersisted: vault.clearUnsaved,
})
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
// Wire conflict file opener now that notes is available
useEffect(() => {
openConflictFileRef.current = (relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = vault.entries.find(e => e.path === fullPath)
if (entry) {
notes.handleSelectNote(entry)
dialogs.closeConflictResolver()
}
}
}, [resolvedPath, vault.entries, notes, dialogs])
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
const handleSave = useCallback(async () => {
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
@@ -248,6 +234,18 @@ function App() {
createTypeEntry: notes.createTypeEntrySilent,
})
const handleDeleteNote = useCallback(async (path: string) => {
try {
if (isTauri()) await invoke('delete_note', { path })
else await mockInvoke('delete_note', { path })
notes.handleCloseTab(path)
vault.removeEntry(path)
setToastMessage('Note permanently deleted')
} catch (e) {
setToastMessage(`Failed to delete note: ${e}`)
}
}, [notes, vault, setToastMessage])
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
const handleCreateType = useCallback((name: string) => {
@@ -272,16 +270,32 @@ function App() {
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
const rawToggleRef = useRef<() => void>(() => {})
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
const diffToggleRef = useRef<() => void>(() => {})
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
const zoom = useZoom()
const buildNumber = useBuildNumber()
const { status: updateStatus, actions: updateActions } = useUpdater()
const handleCheckForUpdates = useCallback(async () => {
const result = await updateActions.checkForUpdates()
if (result === 'up-to-date') {
setToastMessage("You're on the latest version")
} else if (result === 'error') {
setToastMessage('Could not check for updates')
}
// 'available' → UpdateBanner handles it automatically
}, [updateActions, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
entries: vault.entries, allContent: vault.allContent,
modifiedCount: vault.modifiedFiles.length, selection,
modifiedCount: vault.modifiedFiles.length,
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
selection,
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
onCreateNote: notes.handleCreateNoteImmediate,
@@ -291,8 +305,12 @@ function App() {
onOpenSettings: dialogs.openSettings,
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
onCommitPush: commitFlow.openCommitDialog,
onResolveConflicts: handleOpenConflictResolver,
conflictCount: autoSync.conflictFiles.length,
onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: () => rawToggleRef.current(),
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
@@ -303,13 +321,26 @@ function App() {
canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward,
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
onSwitchTheme: themeManager.switchTheme,
onCreateTheme: async () => { await themeManager.createTheme() },
onCreateTheme: async () => {
await themeManager.createTheme()
await vault.reloadVault()
setSelection({ kind: 'sectionGroup', type: 'Theme' })
},
onOpenTheme: (themeId: string) => {
const entry = vault.entries.find(e => e.path === themeId)
if (entry) notes.handleSelectNote(entry)
},
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
isUpdating: updateStatus.state === 'downloading' || updateStatus.state === 'ready',
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
})
const { status: updateStatus, actions: updateActions } = useUpdater()
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
// Show welcome/onboarding screen when vault doesn't exist
@@ -347,7 +378,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -355,7 +386,7 @@ function App() {
{noteListVisible && (
<>
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
@@ -389,6 +420,7 @@ function App() {
vaultPath={resolvedPath}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onDeleteNote={handleDeleteNote}
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
onRenameTab={handleRenameTab}
@@ -396,6 +428,7 @@ function App() {
onSave={handleSave}
onTitleSync={handleTitleSync}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={navHistory.canGoBack}
canGoForward={navHistory.canGoForward}
onGoBack={handleGoBack}
@@ -406,13 +439,24 @@ function App() {
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} indexingProgress={indexing.progress} onRemoveVault={vaultSwitcher.removeVault} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
allResolved={conflictResolver.allResolved}
committing={conflictResolver.committing}
error={conflictResolver.error}
onResolveFile={conflictResolver.resolveFile}
onOpenInEditor={conflictResolver.openInEditor}
onCommit={conflictResolver.commitResolution}
onClose={handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} themeManager={themeManager} />
<GitHubVaultModal
open={dialogs.showGitHubVault}

View File

@@ -0,0 +1,26 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
describe('ArchivedNoteBanner', () => {
it('renders archive icon and label', () => {
render(<ArchivedNoteBanner onUnarchive={vi.fn()} />)
expect(screen.getByTestId('archived-note-banner')).toBeTruthy()
expect(screen.getByText('Archived')).toBeTruthy()
})
it('renders unarchive button with keyboard hint', () => {
render(<ArchivedNoteBanner onUnarchive={vi.fn()} />)
const btn = screen.getByTestId('unarchive-btn')
expect(btn).toBeTruthy()
expect(btn.textContent).toContain('Unarchive')
expect(btn.title).toBe('Unarchive (Cmd+E)')
})
it('calls onUnarchive when button is clicked', () => {
const onUnarchive = vi.fn()
render(<ArchivedNoteBanner onUnarchive={onUnarchive} />)
fireEvent.click(screen.getByTestId('unarchive-btn'))
expect(onUnarchive).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,48 @@
import { Archive, ArrowUUpLeft } from '@phosphor-icons/react'
interface ArchivedNoteBannerProps {
onUnarchive: () => void
}
export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
return (
<div
data-testid="archived-note-banner"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 16px',
background: 'var(--muted)',
borderBottom: '1px solid var(--border)',
fontSize: 12,
color: 'var(--muted-foreground)',
flexShrink: 0,
}}
>
<Archive size={13} weight="bold" />
<span>Archived</span>
<button
data-testid="unarchive-btn"
onClick={onUnarchive}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
marginLeft: 'auto',
padding: '2px 8px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 4,
fontSize: 11,
color: 'var(--muted-foreground)',
cursor: 'pointer',
}}
title="Unarchive (Cmd+E)"
>
<ArrowUUpLeft size={12} />
Unarchive
</button>
</div>
)
}

View File

@@ -51,7 +51,7 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
useEffect(() => {
if (open) {
setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
setSelectedIndex(0)
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])

View File

@@ -0,0 +1,246 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle, FileText, Check, Loader2 } from 'lucide-react'
import type { ConflictFileState } from '../hooks/useConflictResolver'
interface ConflictResolverModalProps {
open: boolean
fileStates: ConflictFileState[]
allResolved: boolean
committing: boolean
error: string | null
onResolveFile: (file: string, strategy: 'ours' | 'theirs') => void
onOpenInEditor: (file: string) => void
onCommit: () => void
onClose: () => void
}
function isBinaryFile(file: string): boolean {
const binaryExts = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.tar', '.gz', '.mp3', '.mp4', '.wav', '.ogg', '.woff', '.woff2', '.ttf', '.otf', '.eot']
return binaryExts.some(ext => file.toLowerCase().endsWith(ext))
}
function fileName(path: string): string {
return path.split('/').pop() ?? path
}
function ResolutionLabel({ resolution }: { resolution: ConflictFileState['resolution'] }) {
if (!resolution) return null
const labels = { ours: 'Keeping mine', theirs: 'Keeping theirs', manual: 'Edited manually' }
return (
<span className="flex items-center gap-1 text-xs text-green-600">
<Check size={12} />{labels[resolution]}
</span>
)
}
function ConflictFileRow({
state,
focused,
onResolve,
onOpenInEditor,
onFocus,
}: {
state: ConflictFileState
focused: boolean
onResolve: (strategy: 'ours' | 'theirs') => void
onOpenInEditor: () => void
onFocus: () => void
}) {
const rowRef = useRef<HTMLDivElement>(null)
const binary = isBinaryFile(state.file)
const resolved = state.resolution !== null
useEffect(() => {
if (focused) rowRef.current?.scrollIntoView({ block: 'nearest' })
}, [focused])
return (
<div
ref={rowRef}
role="row"
tabIndex={0}
onFocus={onFocus}
className={`flex items-center justify-between gap-2 rounded-md border px-3 py-2 transition-colors ${
focused ? 'border-ring bg-accent/50' : 'border-border bg-background'
} ${resolved ? 'opacity-70' : ''}`}
data-testid={`conflict-file-${state.file}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText size={14} className="shrink-0 text-muted-foreground" />
<span className="text-sm truncate" title={state.file}>{fileName(state.file)}</span>
<ResolutionLabel resolution={state.resolution} />
</div>
<div className="flex items-center gap-1 shrink-0">
{state.resolving ? (
<Loader2 size={14} className="animate-spin text-muted-foreground" />
) : (
<>
<Button
variant="outline"
size="sm"
className="text-xs h-7 px-2"
onClick={() => onResolve('ours')}
disabled={state.resolving}
title="Keep my local version (K)"
data-testid={`resolve-ours-${state.file}`}
>
Keep mine
</Button>
<Button
variant="outline"
size="sm"
className="text-xs h-7 px-2"
onClick={() => onResolve('theirs')}
disabled={state.resolving}
title="Keep remote version (T)"
data-testid={`resolve-theirs-${state.file}`}
>
Keep theirs
</Button>
{!binary && (
<Button
variant="ghost"
size="sm"
className="text-xs h-7 px-2"
onClick={onOpenInEditor}
title="Open file in editor (O)"
data-testid={`resolve-open-${state.file}`}
>
Open in editor
</Button>
)}
</>
)}
</div>
</div>
)
}
export function ConflictResolverModal({
open,
fileStates,
allResolved,
committing,
error,
onResolveFile,
onOpenInEditor,
onCommit,
onClose,
}: ConflictResolverModalProps) {
const [focusIdx, setFocusIdx] = useState(0)
const focusIdxRef = useRef(0)
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open) {
setFocusIdx(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
focusIdxRef.current = 0
}
}, [open])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
return
}
const idx = focusIdxRef.current
const file = fileStates[idx]
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault()
const next = Math.min(idx + 1, fileStates.length - 1)
setFocusIdx(next)
focusIdxRef.current = next
return
}
if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault()
const prev = Math.max(idx - 1, 0)
setFocusIdx(prev)
focusIdxRef.current = prev
return
}
if ((e.key === 'k' || e.key === 'K') && file && !file.resolving && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onResolveFile(file.file, 'ours')
} else if ((e.key === 't' || e.key === 'T') && file && !file.resolving && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onResolveFile(file.file, 'theirs')
} else if ((e.key === 'o' || e.key === 'O') && file && !isBinaryFile(file.file) && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onOpenInEditor(file.file)
} else if (e.key === 'Enter' && allResolved && !committing) {
e.preventDefault()
onCommit()
}
}, [fileStates, allResolved, committing, onResolveFile, onOpenInEditor, onCommit, onClose])
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent
showCloseButton={false}
className="sm:max-w-[520px]"
onKeyDown={handleKeyDown}
>
<DialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-orange-500" />
<DialogTitle>Resolve Merge Conflicts</DialogTitle>
</div>
<DialogDescription>
{fileStates.length} file{fileStates.length !== 1 ? 's have' : ' has'} merge conflicts. Choose how to resolve each file.
</DialogDescription>
</DialogHeader>
<div
ref={listRef}
className="flex flex-col gap-2 max-h-[300px] overflow-y-auto"
role="grid"
data-testid="conflict-file-list"
>
{fileStates.map((state, i) => (
<ConflictFileRow
key={state.file}
state={state}
focused={i === focusIdx}
onResolve={(strategy) => onResolveFile(state.file, strategy)}
onOpenInEditor={() => onOpenInEditor(state.file)}
onFocus={() => {
setFocusIdx(i)
focusIdxRef.current = i
}}
/>
))}
</div>
{error && (
<p className="text-xs text-destructive" data-testid="conflict-error">{error}</p>
)}
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">
K = keep mine · T = keep theirs · O = open · Enter = commit
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={onCommit}
disabled={!allResolved || committing}
data-testid="conflict-commit-btn"
>
{committing ? (
<><Loader2 size={14} className="animate-spin mr-1" />Committing</>
) : (
'Commit & continue'
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -51,7 +51,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -129,7 +129,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '1.2px',
letterSpacing: '0',
textTransform: 'uppercase' as const,
overflow: 'hidden',
whiteSpace: 'nowrap' as const,

View File

@@ -44,7 +44,7 @@ vi.mock('@blocknote/react', () => ({
}))
vi.mock('@blocknote/mantine', () => ({
BlockNoteView: ({ children }: { children?: React.ReactNode }) => <div data-testid="blocknote-view">{children}</div>,
BlockNoteView: ({ children, editable }: { children?: React.ReactNode; editable?: boolean }) => <div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>{children}</div>,
}))
vi.mock('@blocknote/mantine/style.css', () => ({}))
@@ -75,7 +75,7 @@ const mockEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -294,6 +294,41 @@ describe('Editor', () => {
mockEditor.replaceBlocks.mockClear()
mockEditor.insertBlocks.mockClear()
})
describe('trashed note behavior', () => {
const trashedEntry: VaultEntry = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
const trashedTab = { entry: trashedEntry, content: mockContent }
function renderTrashed(overrides: Partial<Parameters<typeof Editor>[0]> = {}) {
return render(<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
}
it('shows banner and read-only editor when note is trashed', () => {
renderTrashed()
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
expect(screen.getByText('This note is in the Trash')).toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
})
it('does not show banner and sets editable for normal notes', () => {
render(<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />)
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
})
it('calls onRestoreNote when banner restore is clicked', () => {
const onRestoreNote = vi.fn()
renderTrashed({ onRestoreNote })
fireEvent.click(screen.getByTestId('trashed-banner-restore'))
expect(onRestoreNote).toHaveBeenCalledWith(trashedEntry.path)
})
it('calls onDeleteNote when banner delete is clicked', () => {
const onDeleteNote = vi.fn()
renderTrashed({ onDeleteNote })
fireEvent.click(screen.getByTestId('trashed-banner-delete'))
expect(onDeleteNote).toHaveBeenCalledWith(trashedEntry.path)
})
})
})
describe('wikilink autocomplete', () => {

View File

@@ -50,6 +50,7 @@ interface EditorProps {
vaultPath?: string
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
onRenameTab?: (path: string, newTitle: string) => void
@@ -65,16 +66,19 @@ interface EditorProps {
isDarkTheme?: boolean
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
rawToggleRef?: React.MutableRefObject<() => void>
/** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */
diffToggleRef?: React.MutableRefObject<() => void>
}
function useEditorModeExclusion({
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
}: {
diffMode: boolean
rawMode: boolean
handleToggleDiff: () => void | Promise<void>
handleToggleRaw: () => void
rawToggleRef?: React.MutableRefObject<() => void>
diffToggleRef?: React.MutableRefObject<() => void>
}) {
const handleToggleDiffExclusive = useCallback(async () => {
if (!diffMode && rawMode) handleToggleRaw()
@@ -90,6 +94,10 @@ function useEditorModeExclusion({
if (rawToggleRef) rawToggleRef.current = handleToggleRawExclusive
}, [rawToggleRef, handleToggleRawExclusive])
useEffect(() => {
if (diffToggleRef) diffToggleRef.current = handleToggleDiffExclusive
}, [diffToggleRef, handleToggleDiffExclusive])
return { handleToggleDiffExclusive, handleToggleRawExclusive }
}
@@ -110,11 +118,12 @@ export const Editor = memo(function Editor({
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
showAIChat, onToggleAIChat,
vaultPath,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
onRenameTab, onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
isDarkTheme,
rawToggleRef,
diffToggleRef,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -151,13 +160,12 @@ export const Editor = memo(function Editor({
const { rawMode, handleToggleRaw } = useRawMode({ activeTabPath })
const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
})
const isLoadingNewTab = activeTabPath !== null && !activeTab
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
const showRightPanel = !!(showAIChat || !inspectorCollapsed)
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
@@ -202,13 +210,14 @@ export const Editor = memo(function Editor({
onEditorChange={handleEditorChange}
onTrashNote={onTrashNote}
onRestoreNote={onRestoreNote}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
isDarkTheme={isDarkTheme}
/>
}
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
<EditorRightPanel
showAIChat={showAIChat}
inspectorCollapsed={inspectorCollapsed}

View File

@@ -2,6 +2,8 @@ import type { VaultEntry, NoteStatus } from '../types'
import type { useCreateBlockNote } from '@blocknote/react'
import { DiffView } from './DiffView'
import { BreadcrumbBar } from './BreadcrumbBar'
import { TrashedNoteBanner } from './TrashedNoteBanner'
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
@@ -34,6 +36,7 @@ interface EditorContentProps {
onEditorChange?: () => void
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
@@ -97,7 +100,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
function ActiveTabBreadcrumb({ activeTab, props }: {
activeTab: Tab
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave'>
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
}) {
const wordCount = countWords(activeTab.content)
const path = activeTab.entry.path
@@ -124,14 +127,38 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
)
}
function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed }: {
activeTab: Tab | null; isLoadingNewTab: boolean; entries: VaultEntry[]
editor: ReturnType<typeof useCreateBlockNote>
diffMode: boolean; diffContent: string | null; onToggleDiff: () => void
rawMode: boolean; onRawContentChange?: (path: string, content: string) => void; onSave?: () => void
onNavigateWikilink: (target: string) => void; onEditorChange?: () => void
vaultPath?: string; isDarkTheme?: boolean; isTrashed: boolean
}) {
const showEditor = !diffMode && !rawMode
return (
<>
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} />
{showEditor && activeTab && (
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
</div>
)}
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</>
)
}
export function EditorContent({
activeTab, isLoadingNewTab, entries, editor,
diffMode, diffContent, onToggleDiff,
rawMode, onToggleRaw, onRawContentChange, onSave,
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
onDeleteNote,
...breadcrumbProps
}: EditorContentProps) {
const showEditor = !diffMode && !rawMode
const isTrashed = activeTab?.entry.trashed ?? false
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
@@ -141,14 +168,16 @@ export function EditorContent({
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} />
{showEditor && activeTab && (
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} />
</div>
{activeTab && isTrashed && (
<TrashedNoteBanner
onRestore={() => breadcrumbProps.onRestoreNote?.(activeTab.entry.path)}
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
/>
)}
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
)}
<EditorBody activeTab={activeTab} isLoadingNewTab={isLoadingNewTab} entries={entries} editor={editor} diffMode={diffMode} diffContent={diffContent} onToggleDiff={onToggleDiff} rawMode={rawMode} onRawContentChange={onRawContentChange} onSave={onSave} onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} isTrashed={isTrashed} />
</div>
)
}

View File

@@ -26,7 +26,7 @@ const mockEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -71,7 +71,7 @@ const referrerEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: ['Test Project'],
}
@@ -376,7 +376,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -403,7 +403,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -430,7 +430,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -457,7 +457,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -600,7 +600,7 @@ Status: Active
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
// Body text also links to grow-newsletter
outgoingLinks: ['responsibility/grow-newsletter'],
}

View File

@@ -30,7 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -34,8 +34,9 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
@@ -63,8 +64,9 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/person/matteo-cellini.md',
@@ -89,8 +91,9 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md',
@@ -115,8 +118,9 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/topic/software-development.md',
@@ -141,8 +145,9 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
]
@@ -362,8 +367,9 @@ describe('getSortComparator', () => {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
})
@@ -475,8 +481,9 @@ describe('NoteList sort controls', () => {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
})
@@ -643,6 +650,68 @@ describe('NoteList sort controls', () => {
titles = screen.getAllByText(/Zebra Note|Alpha Note/).map((el) => el.textContent)
expect(titles).toEqual(['Alpha Note', 'Zebra Note'])
})
it('shows custom properties with separator in sort dropdown', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'A', properties: { Priority: 'High', Rating: 5 } }),
makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-separator')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-property:Company')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument()
})
it('omits separator when no custom properties exist', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument()
})
it('sorts entries by custom property when selected', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Rating: 3 } }),
makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: { Rating: 1 } }),
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// Default: modified desc → A, B, C
let titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent)
expect(titles).toEqual(['A', 'B', 'C'])
// Switch to Rating sort (asc by default for properties)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-option-property:Rating'))
// Rating asc: B(1), A(3), C(5)
titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent)
expect(titles).toEqual(['B', 'A', 'C'])
})
it('pushes entries without the property to end when sorting by custom property', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'A', modifiedAt: 3000, properties: { Priority: 'High' } }),
makeEntry({ path: '/b.md', title: 'B', modifiedAt: 2000, properties: {} }),
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-option-property:Priority'))
// Asc: A(High), C(Low), B(null → end)
const titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent)
expect(titles).toEqual(['A', 'C', 'B'])
})
})
// --- Trash feature tests ---
@@ -670,8 +739,9 @@ const trashedEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}
const expiredTrashedEntry: VaultEntry = {
@@ -697,8 +767,9 @@ const expiredTrashedEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}
const entriesWithTrashed = [...mockEntries, trashedEntry, expiredTrashedEntry]
@@ -853,8 +924,9 @@ describe('NoteList — virtual list with large datasets', () => {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
})
@@ -996,6 +1068,33 @@ describe('NoteList — virtual list with large datasets', () => {
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('matches entries by relative path suffix when absolute paths differ (cross-machine)', () => {
// Simulate a cloned vault where cached entries have paths from a different machine
const crossMachineEntries: VaultEntry[] = mockEntries.map((e) => ({
...e,
path: e.path.replace('/Users/luca/Laputa', '/Users/other-machine/OtherVault'),
}))
const modifiedFromCurrentMachine = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
]
render(
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// Even though absolute paths differ, entries should match via relative path suffix
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('shows error message when modifiedFilesError is set', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument()
expect(screen.getByText(/git status failed/)).toBeInTheDocument()
})
it('shows untracked (new) notes alongside modified notes in changes view', () => {
const mixedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
@@ -1155,8 +1254,9 @@ const typeEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}
const entriesWithType = [...mockEntries, typeEntry]

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
import { useDragRegion } from '../hooks/useDragRegion'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
@@ -14,10 +14,11 @@ import { useMultiSelect } from '../hooks/useMultiSelect'
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
import {
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
getSortComparator,
getSortComparator, extractSortableProperties,
buildRelationshipGroups, filterEntries,
relativeDate, getDisplayDate,
loadSortPreferences, saveSortPreferences,
parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage,
} from '../utils/noteListHelpers'
interface NoteListProps {
@@ -26,6 +27,7 @@ interface NoteListProps {
selectedNote: VaultEntry | null
allContent: Record<string, string>
modifiedFiles?: ModifiedFile[]
modifiedFilesError?: string | null
getNoteStatus?: (path: string) => NoteStatus
sidebarCollapsed?: boolean
onSelectNote: (entry: VaultEntry) => void
@@ -33,6 +35,8 @@ interface NoteListProps {
onCreateNote: () => void
onBulkArchive?: (paths: string[]) => void
onBulkTrash?: (paths: string[]) => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
@@ -66,6 +70,7 @@ function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, han
}) {
const groupConfig = sortPrefs[group.label] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
const sortedEntries = [...group.entries].sort(getSortComparator(groupConfig.option, groupConfig.direction))
const customProperties = useMemo(() => extractSortableProperties(group.entries), [group.entries])
return (
<div>
<div className="flex w-full items-center justify-between bg-muted" style={{ height: 32, padding: '0 16px' }}>
@@ -74,7 +79,7 @@ function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, han
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</button>
<span className="flex items-center gap-1.5">
<SortDropdown groupLabel={group.label} current={groupConfig.option} direction={groupConfig.direction} onChange={handleSortChange} />
<SortDropdown groupLabel={group.label} current={groupConfig.option} direction={groupConfig.direction} customProperties={customProperties} onChange={handleSortChange} />
<button className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground" onClick={onToggle}>
{isCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
</button>
@@ -143,13 +148,13 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const emptyText = (isChangesView && changesError) ? `Failed to load changes: ${changesError}` : isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const hasHeader = isTrashView && expiredTrashCount > 0
if (searched.length === 0) {
@@ -232,28 +237,34 @@ function toggleSetMember<T>(set: Set<T>, member: T): Set<T> {
interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
query: string; listSort: SortOption; listDirection: SortDirection
modifiedPathSet: Set<string>
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
}
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet }: NoteListDataParams) {
function isModifiedEntry(path: string, pathSet: Set<string>, suffixes: string[]): boolean {
if (pathSet.has(path)) return true
return suffixes.some((suffix) => path.endsWith(suffix))
}
function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[]) {
const isEntityView = selection.kind === 'entity'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
return useMemo(() => {
if (isEntityView) return []
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
return filterEntries(entries, selection)
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes])
}
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
const searched = useMemo(() => {
if (isEntityView) return []
if (isChangesView) {
const sorted = [...entries.filter((e) => modifiedPathSet.has(e.path))].sort(getSortComparator(listSort, listDirection))
return filterByQuery(sorted, query)
}
const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort, listDirection))
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
return filterByQuery(sorted, query)
}, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet])
}, [filteredEntries, listSort, listDirection, query])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
@@ -266,14 +277,26 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
[isTrashView, searched],
)
return { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount }
return { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount }
}
// --- Pure helpers ---
const DEFAULT_LIST_CONFIG: SortConfig = { option: 'modified', direction: 'desc' }
function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Record<string, SortConfig>): SortConfig {
if (typeDocument?.sort) {
const parsed = parseSortConfig(typeDocument.sort)
if (parsed) return parsed
}
return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG
}
// --- Main component ---
const defaultGetNoteStatus = (): NoteStatus => 'clean'
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -285,14 +308,56 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
[modifiedFiles],
)
// Suffix patterns for cross-machine robustness: if the vault cache carried
// stale absolute paths from another machine, fall back to matching by the
// relative path suffix so the changes view stays in sync with the badge.
const modifiedSuffixes = useMemo(
() => (modifiedFiles ?? []).map((f) => '/' + f.relativePath),
[modifiedFiles],
)
const resolvedGetNoteStatus = useMemo<(path: string) => NoteStatus>(
() => createNoteStatusResolver(getNoteStatus, modifiedFiles, modifiedPathSet),
[getNoteStatus, modifiedFiles, modifiedPathSet],
)
// Resolve the type document for sectionGroup selections (needs to be above sort logic)
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
// Resolve list sort config: read from type frontmatter for sectionGroup, else localStorage
const listConfig = resolveListSortConfig(typeDocument, sortPrefs)
// Silent migration: if type has no sort in frontmatter but localStorage has __list__, migrate it
const migrationDoneRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!typeDocument || typeDocument.sort || !onUpdateTypeSort || !updateEntry) return
if (migrationDoneRef.current.has(typeDocument.path)) return
const lsConfig = sortPrefs['__list__']
if (!lsConfig) return
migrationDoneRef.current.add(typeDocument.path)
const serialized = serializeSortConfig(lsConfig)
onUpdateTypeSort(typeDocument.path, 'sort', serialized)
updateEntry(typeDocument.path, { sort: serialized })
clearListSortFromLocalStorage()
}, [typeDocument, sortPrefs, onUpdateTypeSort, updateEntry])
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}, [])
if (groupLabel === '__list__' && typeDocument && onUpdateTypeSort && updateEntry) {
// Persist sort to type file frontmatter
const config: SortConfig = { option, direction }
const serialized = serializeSortConfig(config)
onUpdateTypeSort(typeDocument.path, 'sort', serialized)
updateEntry(typeDocument.path, { sort: serialized })
// Clear old localStorage __list__ entry if present (migration cleanup)
clearListSortFromLocalStorage()
} else {
// Relationship group sorts still use localStorage
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}
}, [typeDocument, onUpdateTypeSort, updateEntry])
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => toggleSetMember(prev, label))
@@ -300,10 +365,18 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const typeEntryMap = useTypeEntryMap(entries)
const query = search.trim().toLowerCase()
const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
const listSort = listConfig.option
const listDirection = listConfig.direction
const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet })
// Compute custom properties and derive effective sort before sorting entries
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => {
const opt = listConfig.option
if (!opt.startsWith('property:')) return opt
return customProperties.includes(opt.slice('property:'.length)) ? opt : 'modified'
}, [listConfig.option, customProperties])
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const noteListKeyboard = useNoteListKeyboard({
items: searched,
@@ -381,7 +454,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
{resolveHeaderTitle(selection, typeDocument)}
</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} onChange={handleSortChange} />}
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} customProperties={customProperties} onChange={handleSortChange} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
<MagnifyingGlass size={16} />
</button>
@@ -401,7 +474,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
{isEntityView && selection.kind === 'entity' ? (
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
<ListView isTrashView={isTrashView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>

View File

@@ -26,7 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -11,7 +11,8 @@ function entry(title: string, path = `/vault/note/${title}.md`) {
cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, outgoingLinks: [],
sidebarLabel: null, template: null, sort: null, outgoingLinks: [],
properties: {},
}
}

View File

@@ -38,8 +38,9 @@ const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
properties: {},
},
{
path: '/vault/event/retreat.md',
@@ -64,8 +65,9 @@ const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: ['person/bob'],
properties: {},
},
]

View File

@@ -209,7 +209,6 @@ function SearchContent({
onMouseEnter={() => onHover(i)}
>
<div className="flex items-center gap-2">
{/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */}
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{result.title}</span>
{noteType && (

View File

@@ -349,7 +349,7 @@ function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) {
<button
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground hover:border-foreground"
style={{ fontSize: 12, padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 4, alignSelf: 'flex-start' }}
onClick={() => createTheme(activeThemeId ?? undefined)}
onClick={() => createTheme()}
type="button"
data-testid="create-theme"
>

View File

@@ -40,8 +40,9 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/responsibility/grow-newsletter.md',
@@ -67,8 +68,9 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/experiment/stock-screener.md',
@@ -94,8 +96,9 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/procedure/weekly-essays.md',
@@ -121,8 +124,9 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/topic/software-development.md',
@@ -148,8 +152,9 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/topic/trading.md',
@@ -175,8 +180,9 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/person/alice.md',
@@ -202,8 +208,9 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/event/kickoff.md',
@@ -229,8 +236,9 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
]
@@ -449,8 +457,9 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/type/book.md',
@@ -476,8 +485,9 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/recipe/pasta.md',
@@ -502,8 +512,9 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/book/ddia.md',
@@ -528,8 +539,9 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
]
@@ -576,6 +588,7 @@ describe('Sidebar', () => {
cadence: null, archived: false, trashed: true, trashedAt: 1700000000,
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
properties: {},
},
]
render(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
@@ -614,8 +627,9 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
// "Projects" should appear once (the built-in section), not twice
@@ -632,6 +646,7 @@ describe('Sidebar', () => {
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: 'News', outgoingLinks: [],
properties: {},
},
{
path: '/vault/news/breaking.md', filename: 'breaking.md', title: 'Breaking Story', isA: 'News',
@@ -639,6 +654,7 @@ describe('Sidebar', () => {
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 300, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null, outgoingLinks: [],
properties: {},
},
]
render(<Sidebar entries={entriesWithLabel} selection={defaultSelection} onSelect={() => {}} />)
@@ -656,6 +672,7 @@ describe('Sidebar', () => {
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: 'Contacts', outgoingLinks: [],
properties: {},
},
]
render(<Sidebar entries={entriesWithBuiltInOverride} selection={defaultSelection} onSelect={() => {}} />)
@@ -776,6 +793,7 @@ describe('Sidebar', () => {
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 5, sidebarLabel: null, outgoingLinks: [],
properties: {},
},
{
path: '/vault/type/topic.md', filename: 'topic.md', title: 'Topic', isA: 'Type',
@@ -783,6 +801,7 @@ describe('Sidebar', () => {
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 0, sidebarLabel: null, outgoingLinks: [],
properties: {},
},
{
path: '/vault/type/person.md', filename: 'person.md', title: 'Person', isA: 'Type',
@@ -790,6 +809,7 @@ describe('Sidebar', () => {
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null, fileSize: 200, snippet: '',
wordCount: 0,
relationships: {}, icon: null, color: null, order: 1, sidebarLabel: null, outgoingLinks: [],
properties: {},
},
]
@@ -809,11 +829,67 @@ describe('Sidebar', () => {
expect(topicsIdx).toBeLessThan(peopleIdx)
})
it('renders drag handle on section headers', () => {
it('does not render drag handle icons on section headers', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const dragHandles = screen.getAllByLabelText(/^Drag to reorder/)
// Should have one drag handle per visible section group
expect(dragHandles.length).toBeGreaterThan(0)
const dragHandles = screen.queryAllByLabelText(/^Drag to reorder/)
expect(dragHandles.length).toBe(0)
})
})
describe('rename section via context menu', () => {
it('shows Rename section option in context menu on right-click', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
expect(screen.getByText('Rename section…')).toBeInTheDocument()
})
it('shows Customize icon option in context menu on right-click', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
expect(screen.getByText('Customize icon & color…')).toBeInTheDocument()
})
it('shows inline input when Rename section is clicked', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
fireEvent.click(screen.getByText('Rename section…'))
expect(screen.getByRole('textbox', { name: 'Section name' })).toBeInTheDocument()
})
it('inline input is pre-filled with current label', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
fireEvent.click(screen.getByText('Rename section…'))
const input = screen.getByRole('textbox', { name: 'Section name' }) as HTMLInputElement
expect(input.value).toBe('Projects')
})
it('calls onRenameSection with new name on Enter', () => {
const onRenameSection = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onRenameSection={onRenameSection} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
fireEvent.click(screen.getByText('Rename section…'))
const input = screen.getByRole('textbox', { name: 'Section name' })
fireEvent.change(input, { target: { value: 'My Projects' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onRenameSection).toHaveBeenCalledWith('Project', 'My Projects')
})
it('cancels rename on Escape and hides input', () => {
const onRenameSection = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onRenameSection={onRenameSection} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
fireEvent.click(screen.getByText('Rename section…'))
const input = screen.getByRole('textbox', { name: 'Section name' })
fireEvent.keyDown(input, { key: 'Escape' })
expect(onRenameSection).not.toHaveBeenCalled()
expect(screen.queryByRole('textbox', { name: 'Section name' })).not.toBeInTheDocument()
})
})
})

View File

@@ -34,6 +34,7 @@ interface SidebarProps {
onCustomizeType?: (typeName: string, icon: string, color: string) => void
onUpdateTypeTemplate?: (typeName: string, template: string) => void
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
onRenameSection?: (typeName: string, label: string) => void
modifiedCount?: number
onCommitPush?: () => void
onCollapse?: () => void
@@ -161,12 +162,13 @@ function applyCustomization(
function SortableSection({ group, sectionProps }: {
group: SectionGroup
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'dragHandleProps' | 'onToggle'>
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void }
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'onToggle' | 'isRenaming' | 'renameInitialValue'>
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string }
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const { attributes, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed)
const isCollapsed = sectionProps.collapsed[group.type] ?? true
const isRenaming = sectionProps.renamingType === group.type
return (
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '4px 6px' }} {...attributes}>
@@ -176,7 +178,10 @@ function SortableSection({ group, sectionProps }: {
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
onToggle={() => sectionProps.onToggle(group.type)}
dragHandleProps={listeners}
isRenaming={isRenaming}
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
onRenameSubmit={sectionProps.onRenameSubmit}
onRenameCancel={sectionProps.onRenameCancel}
/>
</div>
)
@@ -216,16 +221,21 @@ function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
)
}
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize, onStartRename }: {
pos: { x: number; y: number } | null; type: string | null
innerRef: React.Ref<HTMLDivElement>
onOpenCustomize: (type: string) => void
onStartRename: (type: string) => void
}) {
if (!pos || !type) return null
const btnClass = "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left"
return (
<div ref={innerRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: pos.x, top: pos.y, minWidth: 180 }}>
<button className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left" onClick={() => onOpenCustomize(type)}>
Customize icon & color
<button className={btnClass} onClick={() => onStartRename(type)}>
Rename section
</button>
<button className={btnClass} onClick={() => onOpenCustomize(type)}>
Customize icon &amp; color
</button>
</div>
)
@@ -258,11 +268,14 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
modifiedCount = 0, onCommitPush, onCollapse,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
const [renamingType, setRenamingType] = useState<string | null>(null)
const [renameInitialValue, setRenameInitialValue] = useState('')
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
const [showCustomize, setShowCustomize] = useState(false)
@@ -303,6 +316,20 @@ export const Sidebar = memo(function Sidebar({
setContextMenuPos({ x: e.clientX, y: e.clientY }); setContextMenuType(type)
}, [])
const cancelRename = useCallback(() => setRenamingType(null), [])
const handleStartRename = useCallback((type: string) => {
closeContextMenu()
const group = allSectionGroups.find((g) => g.type === type)
setRenameInitialValue(group?.label ?? type)
setRenamingType(type)
}, [closeContextMenu, allSectionGroups])
const handleRenameSubmit = useCallback((value: string) => {
if (renamingType) onRenameSection?.(renamingType, value)
setRenamingType(null)
}, [renamingType, onRenameSection])
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {
applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value)
}, [customizeTarget, typeEntryMap, onCustomizeType])
@@ -314,6 +341,7 @@ export const Sidebar = memo(function Sidebar({
const sectionProps = {
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onContextMenu: handleContextMenu, onToggle: toggleSection,
renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename,
}
return (
@@ -354,7 +382,7 @@ export const Sidebar = memo(function Sidebar({
</nav>
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
</aside>
)

View File

@@ -1,7 +1,7 @@
import { type ComponentType } from 'react'
import { type ComponentType, useState, useEffect, useRef } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { ChevronRight, ChevronDown, Plus, GripVertical } from 'lucide-react'
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
@@ -75,7 +75,10 @@ export interface SectionContentProps {
onCreateNewType?: () => void
onContextMenu: (e: React.MouseEvent, type: string) => void
onToggle: () => void
dragHandleProps?: Record<string, unknown>
isRenaming?: boolean
renameInitialValue?: string
onRenameSubmit?: (value: string) => void
onRenameCancel?: () => void
}
function childSelection(type: string, entry: VaultEntry): SidebarSelection {
@@ -90,7 +93,8 @@ function resolveCreateHandler(type: string, onCreateType?: (type: string) => voi
export function SectionContent({
group, items, isCollapsed, selection, onSelect, onSelectNote,
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
onCreateType, onCreateNewType, onContextMenu, onToggle,
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
}: SectionContentProps) {
const { label, type, Icon, customColor } = group
const sectionColor = getTypeColor(type, customColor)
@@ -109,7 +113,10 @@ export function SectionContent({
onContextMenu={(e) => onContextMenu(e, type)}
onToggle={onToggle}
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
dragHandleProps={dragHandleProps}
isRenaming={isRenaming}
renameInitialValue={renameInitialValue}
onRenameSubmit={onRenameSubmit}
onRenameCancel={onRenameCancel}
/>
{!isCollapsed && items.length > 0 && (
<SectionChildList
@@ -145,29 +152,67 @@ function SectionChildList({ items, type, selection, sectionColor, sectionLightCo
)
}
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps }: {
function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
initialValue: string
onSubmit: (value: string) => void
onCancel: () => void
}) {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { inputRef.current?.focus(); inputRef.current?.select() }, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onSubmit(value.trim()) }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel() }
}
return (
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSubmit(value.trim())}
onClick={(e) => e.stopPropagation()}
aria-label="Section name"
className="flex-1 rounded border border-primary bg-background text-[13px] font-medium text-foreground outline-none"
style={{ padding: '1px 4px' }}
/>
)
}
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
label: string; type: string; Icon: ComponentType<IconProps>
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
dragHandleProps?: Record<string, unknown>
isRenaming?: boolean; renameInitialValue?: string
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
}) {
return (
<div
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
style={{ padding: '6px 8px 6px 6px', borderRadius: 4, gap: 4 }}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
onClick={() => {
if (isRenaming) return
if (isCollapsed) { onToggle(); onSelect() }
else if (isActive) { onToggle() }
else { onSelect() }
}} onContextMenu={onContextMenu}
}} onContextMenu={isRenaming ? undefined : onContextMenu}
>
<div className="flex items-center" style={{ gap: 4 }}>
<div className="flex shrink-0 items-center justify-center text-muted-foreground opacity-0 group-hover/section:opacity-50 hover:!opacity-100 cursor-grab" style={{ width: 16, height: 16 }} {...dragHandleProps} aria-label={`Drag to reorder ${label}`}>
<GripVertical size={12} />
</div>
<Icon size={16} style={{ color: sectionColor }} />
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<Icon size={16} style={{ color: sectionColor, flexShrink: 0 }} />
{isRenaming && onRenameSubmit && onRenameCancel ? (
<InlineRenameInput
key={`rename-${type}`}
initialValue={renameInitialValue ?? label}
onSubmit={onRenameSubmit}
onCancel={onRenameCancel}
/>
) : (
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
)}
</div>
<div className="flex items-center" style={{ gap: 2 }}>
{showCreate && (

View File

@@ -23,13 +23,14 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
}
/** Single BlockNote editor view — content is swapped via replaceBlocks */
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme }: {
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme, editable = true }: {
editor: ReturnType<typeof useCreateBlockNote>
entries: VaultEntry[]
onNavigateWikilink: (target: string) => void
onChange?: () => void
vaultPath?: string
isDarkTheme?: boolean
editable?: boolean
}) {
const navigateRef = useRef(onNavigateWikilink)
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
@@ -103,6 +104,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
editor={editor}
theme={isDarkTheme ? 'dark' : 'light'}
onChange={onChange}
editable={editable}
>
<SuggestionMenuController
triggerCharacter="[["

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useRef } from 'react'
import { cn } from '@/lib/utils'
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
import { type SortOption, type SortDirection, DEFAULT_DIRECTIONS, SORT_OPTIONS } from '../utils/noteListHelpers'
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS, getSortOptionLabel } from '../utils/noteListHelpers'
export function SortDropdown({ groupLabel, current, direction, onChange }: {
export function SortDropdown({ groupLabel, current, direction, customProperties, onChange }: {
groupLabel: string
current: SortOption
direction: SortDirection
customProperties?: string[]
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const [open, setOpen] = useState(false)
@@ -27,58 +28,81 @@ export function SortDropdown({ groupLabel, current, direction, onChange }: {
}
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
const hasCustom = customProperties && customProperties.length > 0
return (
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<button
className={cn("flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent", open && "bg-accent text-foreground")}
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
title={`Sort by ${current}`}
title={`Sort by ${getSortOptionLabel(current)}`}
data-testid={`sort-button-${groupLabel}`}
>
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
<span className="text-[10px] font-medium">{SORT_OPTIONS.find((o) => o.value === current)?.label}</span>
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md" style={{ width: 150, padding: 4 }} data-testid={`sort-menu-${groupLabel}`}>
{SORT_OPTIONS.map((opt) => {
const isActive = opt.value === current
return (
<div
key={opt.value}
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
data-testid={`sort-option-${opt.value}`}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, isActive ? direction : DEFAULT_DIRECTIONS[opt.value]) }}
>
<span className="flex flex-1 items-center gap-1.5 text-inherit">
{opt.label}
</span>
<span className="flex items-center gap-0.5 ml-1">
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, 'asc') }}
data-testid={`sort-dir-asc-${opt.value}`}
title="Ascending"
>
<ArrowUp size={12} />
</button>
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, 'desc') }}
data-testid={`sort-dir-desc-${opt.value}`}
title="Descending"
>
<ArrowDown size={12} />
</button>
</span>
</div>
)
})}
<div
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md"
style={{ width: 170, padding: 4, maxHeight: 280, overflowY: 'auto' }}
data-testid={`sort-menu-${groupLabel}`}
>
{SORT_OPTIONS.map((opt) => (
<SortRow key={opt.value} value={opt.value} label={opt.label} current={current} direction={direction} onSelect={handleSelect} />
))}
{hasCustom && (
<>
<div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />
{customProperties.map((key) => {
const value: SortOption = `property:${key}`
return <SortRow key={value} value={value} label={key} current={current} direction={direction} onSelect={handleSelect} />
})}
</>
)}
</div>
)}
</div>
)
}
function SortRow({ value, label, current, direction, onSelect }: {
value: SortOption
label: string
current: SortOption
direction: SortDirection
onSelect: (opt: SortOption, dir: SortDirection) => void
}) {
const isActive = value === current
return (
<div
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
data-testid={`sort-option-${value}`}
onClick={(e) => { e.stopPropagation(); onSelect(value, isActive ? direction : getDefaultDirection(value)) }}
>
<span className="flex flex-1 items-center gap-1.5 text-inherit truncate">
{label}
</span>
<span className="flex items-center gap-0.5 ml-1 shrink-0">
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); onSelect(value, 'asc') }}
data-testid={`sort-dir-asc-${value}`}
title="Ascending"
>
<ArrowUp size={12} />
</button>
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); onSelect(value, 'desc') }}
data-testid={`sort-dir-desc-${value}`}
title="Descending"
>
<ArrowDown size={12} />
</button>
</span>
</div>
)
}

View File

@@ -212,4 +212,90 @@ describe('StatusBar', () => {
)
expect(screen.getByTitle('View pending changes')).toBeInTheDocument()
})
it('shows indexing badge when indexing is in progress', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'scanning', current: 342, total: 1057, done: false, error: null }}
/>
)
expect(screen.getByTestId('status-indexing')).toBeInTheDocument()
expect(screen.getByText(/Indexing… 342\/1,057/)).toBeInTheDocument()
})
it('shows embedding phase in indexing badge', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'embedding', current: 50, total: 200, done: false, error: null }}
/>
)
expect(screen.getByText(/Embedding… 50\/200/)).toBeInTheDocument()
})
it('shows index ready when indexing is complete', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'complete', current: 1057, total: 1057, done: true, error: null }}
/>
)
expect(screen.getByText('Index ready')).toBeInTheDocument()
})
it('shows error state in indexing badge', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd not available' }}
/>
)
expect(screen.getByText('Index error')).toBeInTheDocument()
})
it('hides indexing badge when phase is idle', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
/>
)
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
})
it('hides indexing badge when no progress prop provided', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
)
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
})
it('shows installing phase in indexing badge', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'installing', current: 0, total: 0, done: false, error: null }}
/>
)
expect(screen.getByText('Installing search…')).toBeInTheDocument()
})
})

View File

@@ -1,11 +1,13 @@
import { useState, useRef, useEffect } from 'react'
import { Package, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal } from 'lucide-react'
import { Package, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X } from 'lucide-react'
import type { LastCommitInfo, SyncStatus } from '../types'
import type { IndexingProgress } from '../hooks/useIndexing'
import { openExternalUrl } from '../utils/url'
export interface VaultOption {
label: string
path: string
available?: boolean
}
interface StatusBarProps {
@@ -24,30 +26,65 @@ interface StatusBarProps {
conflictCount?: number
lastCommitInfo?: LastCommitInfo | null
onTriggerSync?: () => void
onOpenConflictResolver?: () => void
zoomLevel?: number
onZoomReset?: () => void
buildNumber?: string
indexingProgress?: IndexingProgress
onRemoveVault?: (path: string) => void
}
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
if (isActive) return <Check size={12} />
if (unavailable) return <AlertTriangle size={12} style={{ color: 'var(--muted-foreground)' }} />
return <span style={{ width: 12 }} />
}
function vaultItemStyle(isActive: boolean, unavailable: boolean): React.CSSProperties {
return {
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
cursor: unavailable ? 'not-allowed' : 'pointer',
background: isActive ? 'var(--hover)' : 'transparent',
opacity: unavailable ? 0.45 : 1,
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
}
}
function VaultMenuItem({ vault, isActive, onSelect, onRemove, canRemove }: { vault: VaultOption; isActive: boolean; onSelect: () => void; onRemove?: () => void; canRemove?: boolean }) {
const unavailable = vault.available === false
const canHover = !isActive && !unavailable
return (
<div
role="button" onClick={onSelect}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, cursor: 'pointer',
background: isActive ? 'var(--hover)' : 'transparent',
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent' }}
role="button"
onClick={unavailable ? undefined : onSelect}
style={{ ...vaultItemStyle(isActive, unavailable), justifyContent: 'space-between' }}
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
onMouseEnter={canHover ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={canHover ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
data-testid={`vault-menu-item-${vault.label}`}
>
{isActive ? <Check size={12} /> : <span style={{ width: 12 }} />}
{vault.label}
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
{vault.label}
</span>
{canRemove && onRemove && (
<span
role="button"
onClick={(e) => { e.stopPropagation(); onRemove() }}
style={{ display: 'flex', alignItems: 'center', padding: 2, borderRadius: 3, cursor: 'pointer', opacity: 0.5 }}
title="Remove from list"
data-testid={`vault-menu-remove-${vault.label}`}
onMouseEnter={e => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.opacity = '0.5'; e.currentTarget.style.background = 'transparent' }}
>
<X size={10} />
</span>
)}
</div>
)
}
function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConnectGitHub, hasGitHub }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void; onOpenLocalFolder?: () => void; onConnectGitHub?: () => void; hasGitHub?: boolean }) {
function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConnectGitHub, hasGitHub, onRemoveVault }: { vaults: VaultOption[]; vaultPath: string; onSwitchVault: (path: string) => void; onOpenLocalFolder?: () => void; onConnectGitHub?: () => void; hasGitHub?: boolean; onRemoveVault?: (path: string) => void }) {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const activeVault = vaults.find((v) => v.path === vaultPath)
@@ -69,7 +106,7 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConn
</span>
{open && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, background: 'var(--sidebar)', border: '1px solid var(--border)', borderRadius: 6, padding: 4, minWidth: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.3)', zIndex: 1000 }}>
{vaults.map((v) => <VaultMenuItem key={v.path} vault={v} isActive={v.path === vaultPath} onSelect={() => { onSwitchVault(v.path); setOpen(false) }} />)}
{vaults.map((v) => <VaultMenuItem key={v.path} vault={v} isActive={v.path === vaultPath} onSelect={() => { onSwitchVault(v.path); setOpen(false) }} onRemove={() => { onRemoveVault?.(v.path); setOpen(false) }} canRemove={!!onRemoveVault && vaults.length > 1} />)}
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
{onOpenLocalFolder && (
<div
@@ -116,21 +153,21 @@ const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cu
const SEP_STYLE = { color: 'var(--border)' } as const
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
if (status === 'syncing') return 'Syncing…'
if (status === 'conflict') return 'Conflict'
if (status === 'error') return 'Sync failed'
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' }
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' }
function formatElapsedSync(lastSyncTime: number | null): string {
if (!lastSyncTime) return 'Not synced'
const elapsed = Math.round((Date.now() - lastSyncTime) / 1000)
if (elapsed < 60) return 'Synced just now'
const mins = Math.floor(elapsed / 60)
return `Synced ${mins}m ago`
const secs = Math.round((Date.now() - lastSyncTime) / 1000)
return secs < 60 ? 'Synced just now' : `Synced ${Math.floor(secs / 60)}m ago`
}
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
return SYNC_LABELS[status] ?? formatElapsedSync(lastSyncTime)
}
function syncIconColor(status: SyncStatus): string {
if (status === 'conflict') return 'var(--accent-orange)'
if (status === 'error') return 'var(--muted-foreground)'
return 'var(--accent-green)'
return SYNC_COLORS[status] ?? 'var(--accent-green)'
}
function CommitBadge({ info }: { info: LastCommitInfo }) {
@@ -156,56 +193,113 @@ function CommitBadge({ info }: { info: LastCommitInfo }) {
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber }: StatusBarProps) {
// Force re-render every 30s to keep relative time label fresh
function SyncBadge({ status, lastSyncTime, onTriggerSync, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void; onOpenConflictResolver?: () => void }) {
const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw
const isSyncing = status === 'syncing'
const isConflict = status === 'conflict'
const handleClick = isConflict ? onOpenConflictResolver : onTriggerSync
return (
<span
role="button"
onClick={handleClick}
style={{ ...ICON_STYLE, cursor: handleClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
title={isConflict ? 'Click to resolve conflicts' : isSyncing ? 'Syncing…' : 'Click to sync now'}
data-testid="status-sync"
>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
</span>
)
}
function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void }) {
if (count <= 0) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)', cursor: onClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Resolve merge conflicts"
onMouseEnter={onClick ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onClick ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
data-testid="status-conflict-count"
>
<AlertTriangle size={13} />{count} conflict{count > 1 ? 's' : ''}
</span>
</>
)
}
const INDEXING_LABELS: Record<string, string> = {
installing: 'Installing search…',
scanning: 'Indexing…',
embedding: 'Embedding…',
complete: 'Index ready',
error: 'Index error',
}
function IndexingBadge({ progress }: { progress: IndexingProgress }) {
if (progress.phase === 'idle') return null
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
const isActive = !progress.done
const showCount = progress.total > 0 && isActive
const displayText = showCount
? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}`
: label
const color = progress.phase === 'error' ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
return (
<>
<span style={SEP_STYLE}>|</span>
<span style={{ ...ICON_STYLE, color }} data-testid="status-indexing">
{isActive
? <Loader2 size={13} className="animate-spin" />
: <Search size={13} />
}
{displayText}
</span>
</>
)
}
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
if (count <= 0) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="View pending changes"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-modified-count"
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{count} pending</span>
</>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, indexingProgress, onRemoveVault }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
return () => clearInterval(id)
}, [])
const syncLabel = formatSyncLabel(syncStatus, lastSyncTime)
const SyncIcon = SYNC_ICON_MAP[syncStatus] ?? RefreshCw
return (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} />
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} onRemoveVault={onRemoveVault} />
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE} data-testid="status-build-number"><Package size={13} />{buildNumber ?? 'b?'}</span>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onTriggerSync}
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
title={syncStatus === 'syncing' ? 'Syncing…' : 'Click to sync now'}
data-testid="status-sync"
>
<SyncIcon size={13} style={{ color: syncIconColor(syncStatus) }} className={syncStatus === 'syncing' ? 'animate-spin' : ''} />{syncLabel}
</span>
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} onOpenConflictResolver={onOpenConflictResolver} />
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
{conflictCount > 0 && (
<>
<span style={SEP_STYLE}>|</span>
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
<AlertTriangle size={13} />{conflictCount} conflict{conflictCount > 1 ? 's' : ''}
</span>
</>
)}
{modifiedCount > 0 && (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onClickPending}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="View pending changes"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-modified-count"
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{modifiedCount} pending</span>
</>
)}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
{indexingProgress && <IndexingBadge progress={indexingProgress} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>

View File

@@ -16,7 +16,7 @@ export function StatusPill({ status, className }: { status: string; className?:
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '1.2px',
letterSpacing: '0',
textTransform: 'uppercase' as const,
maxWidth: 160,
}}
@@ -101,7 +101,7 @@ const SECTION_LABEL_STYLE = {
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
letterSpacing: '0',
textTransform: 'uppercase' as const,
}

View File

@@ -11,7 +11,7 @@ function makeEntry(path: string, title: string): VaultEntry {
status: null, owner: null, cadence: null, archived: false,
trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
}
}

View File

@@ -0,0 +1,31 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TrashedNoteBanner } from './TrashedNoteBanner'
describe('TrashedNoteBanner', () => {
it('renders the banner with trash message', () => {
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={vi.fn()} />)
expect(screen.getByText('This note is in the Trash')).toBeInTheDocument()
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
})
it('shows Restore and Delete permanently buttons', () => {
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={vi.fn()} />)
expect(screen.getByText('Restore')).toBeInTheDocument()
expect(screen.getByText('Delete permanently')).toBeInTheDocument()
})
it('calls onRestore when Restore button is clicked', () => {
const onRestore = vi.fn()
render(<TrashedNoteBanner onRestore={onRestore} onDeletePermanently={vi.fn()} />)
fireEvent.click(screen.getByTestId('trashed-banner-restore'))
expect(onRestore).toHaveBeenCalledOnce()
})
it('calls onDeletePermanently when Delete permanently button is clicked', () => {
const onDeletePermanently = vi.fn()
render(<TrashedNoteBanner onRestore={vi.fn()} onDeletePermanently={onDeletePermanently} />)
fireEvent.click(screen.getByTestId('trashed-banner-delete'))
expect(onDeletePermanently).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,44 @@
import { memo } from 'react'
import { Trash, ArrowCounterClockwise } from '@phosphor-icons/react'
interface TrashedNoteBannerProps {
onRestore: () => void
onDeletePermanently: () => void
}
export const TrashedNoteBanner = memo(function TrashedNoteBanner({
onRestore,
onDeletePermanently,
}: TrashedNoteBannerProps) {
return (
<div
className="flex shrink-0 items-center gap-3"
style={{
padding: '6px 16px',
background: 'var(--destructive-muted, color-mix(in srgb, var(--destructive) 8%, var(--background)))',
borderBottom: '1px solid var(--border)',
fontSize: 12,
}}
data-testid="trashed-note-banner"
>
<Trash size={14} style={{ color: 'var(--destructive)', flexShrink: 0 }} />
<span className="text-muted-foreground" style={{ flex: 1 }}>This note is in the Trash</span>
<button
className="flex items-center gap-1 border-none bg-transparent px-2 py-0.5 text-xs cursor-pointer rounded transition-colors text-primary hover:bg-accent"
onClick={onRestore}
data-testid="trashed-banner-restore"
>
<ArrowCounterClockwise size={12} />
Restore
</button>
<button
className="flex items-center gap-1 border-none bg-transparent px-2 py-0.5 text-xs cursor-pointer rounded transition-colors text-destructive hover:bg-destructive/10"
onClick={onDeletePermanently}
data-testid="trashed-banner-delete"
>
<Trash size={12} />
Delete permanently
</button>
</div>
)
})

View File

@@ -31,9 +31,13 @@ interface AppCommandsConfig {
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
onResolveConflicts?: () => void
conflictCount?: number
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
activeNoteModified: boolean
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
@@ -51,8 +55,16 @@ interface AppCommandsConfig {
activeThemeId?: string | null
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
onOpenTheme?: (themeId: string) => void
onOpenVault?: () => void
onCreateType?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
isUpdating?: boolean
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
isGettingStartedHidden?: boolean
vaultCount?: number
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -129,9 +141,13 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onArchiveNote: config.onArchiveNote,
onUnarchiveNote: config.onUnarchiveNote,
onCommitPush: config.onCommitPush,
onResolveConflicts: config.onResolveConflicts,
conflictCount: config.conflictCount,
onSetViewMode: config.onSetViewMode,
onToggleInspector: config.onToggleInspector,
onToggleDiff: config.onToggleDiff,
onToggleRawEditor: config.onToggleRawEditor,
activeNoteModified: config.activeNoteModified,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
@@ -147,8 +163,16 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
activeThemeId: config.activeThemeId,
onSwitchTheme: config.onSwitchTheme,
onCreateTheme: config.onCreateTheme,
onOpenTheme: config.onOpenTheme,
onOpenVault: config.onOpenVault,
onCreateType: config.onCreateType,
onToggleAIChat: config.onToggleAIChat,
onCheckForUpdates: config.onCheckForUpdates,
isUpdating: config.isUpdating,
onRemoveActiveVault: config.onRemoveActiveVault,
onRestoreGettingStarted: config.onRestoreGettingStarted,
isGettingStartedHidden: config.isGettingStartedHidden,
vaultCount: config.vaultCount,
})
useKeyboardNavigation({

View File

@@ -194,6 +194,28 @@ describe('useAutoSync', () => {
})
})
it('skips pull when paused via pausePull', async () => {
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
})
// Pause and clear mocks
act(() => { result.current.pausePull() })
mockInvokeFn.mockClear()
// Trigger sync while paused
act(() => { result.current.triggerSync() })
// Should not have called git_pull
const pullCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'git_pull').length
expect(pullCalls).toBe(0)
// Resume
act(() => { result.current.resumePull() })
})
it('handles error status from git_pull result', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_last_commit_info') return Promise.resolve(null)

View File

@@ -23,6 +23,10 @@ export interface AutoSyncState {
conflictFiles: string[]
lastCommitInfo: LastCommitInfo | null
triggerSync: () => void
/** Pause auto-pull (e.g. while conflict resolver modal is open). */
pausePull: () => void
/** Resume auto-pull after pausing. */
resumePull: () => void
}
export function useAutoSync({
@@ -37,11 +41,12 @@ export function useAutoSync({
const [conflictFiles, setConflictFiles] = useState<string[]>([])
const [lastCommitInfo, setLastCommitInfo] = useState<LastCommitInfo | null>(null)
const syncingRef = useRef(false)
const pauseRef = useRef(false)
const callbacksRef = useRef({ onVaultUpdated, onConflict, onToast })
callbacksRef.current = { onVaultUpdated, onConflict, onToast }
const performPull = useCallback(async () => {
if (syncingRef.current) return
if (syncingRef.current || pauseRef.current) return
syncingRef.current = true
setSyncStatus('syncing')
@@ -95,5 +100,8 @@ export function useAutoSync({
return () => clearInterval(id)
}, [performPull, intervalMinutes])
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, triggerSync: performPull }
const pausePull = useCallback(() => { pauseRef.current = true }, [])
const resumePull = useCallback(() => { pauseRef.current = false }, [])
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, triggerSync: performPull, pausePull, resumePull }
}

View File

@@ -26,7 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})
@@ -36,6 +36,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
activeTabPath: null as string | null,
entries: [] as VaultEntry[],
modifiedCount: 0,
activeNoteModified: false,
onQuickOpen: vi.fn(),
onCreateNote: vi.fn(),
onCreateNoteOfType: vi.fn(),
@@ -264,6 +265,82 @@ describe('useCommandRegistry', () => {
expect(onToggleAIChat).toHaveBeenCalled()
})
it('has toggle-diff command in View group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
expect(cmd!.label).toBe('Toggle Diff Mode')
})
it('disables toggle-diff when no note is open', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd!.enabled).toBe(false)
})
it('disables toggle-diff when note has no changes', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: false })),
)
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd!.enabled).toBe(false)
})
it('enables toggle-diff when note has uncommitted changes', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: true })),
)
const cmd = result.current.find(c => c.id === 'toggle-diff')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleDiff when toggle-diff executes', () => {
const onToggleDiff = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', activeNoteModified: true, onToggleDiff })),
)
result.current.find(c => c.id === 'toggle-diff')!.execute()
expect(onToggleDiff).toHaveBeenCalledOnce()
})
it('has toggle-backlinks command in View group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('View')
expect(cmd!.label).toBe('Toggle Backlinks')
})
it('disables toggle-backlinks when no note is open', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
expect(cmd!.enabled).toBe(false)
})
it('enables toggle-backlinks when a note is open', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md' })),
)
const cmd = result.current.find(c => c.id === 'toggle-backlinks')
expect(cmd!.enabled).toBe(true)
})
it('calls onToggleInspector when toggle-backlinks executes', () => {
const onToggleInspector = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', onToggleInspector })),
)
result.current.find(c => c.id === 'toggle-backlinks')!.execute()
expect(onToggleInspector).toHaveBeenCalledOnce()
})
it('toggle-inspector label includes Properties', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'toggle-inspector')
expect(cmd!.label).toBe('Toggle Properties Panel')
})
it('has open-daily-note command with shortcut', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'open-daily-note')
@@ -281,6 +358,76 @@ describe('useCommandRegistry', () => {
expect(onOpenDailyNote).toHaveBeenCalled()
})
describe('check-updates command', () => {
it('has check-updates command in Settings group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'check-updates')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('Check for Updates')
expect(cmd!.group).toBe('Settings')
expect(cmd!.keywords).toContain('update')
expect(cmd!.keywords).toContain('version')
})
it('is enabled when not updating', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ isUpdating: false })),
)
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(true)
})
it('is disabled when updating', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ isUpdating: true })),
)
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(false)
})
it('calls onCheckForUpdates when executed', () => {
const onCheckForUpdates = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ onCheckForUpdates })),
)
result.current.find(c => c.id === 'check-updates')!.execute()
expect(onCheckForUpdates).toHaveBeenCalled()
})
})
describe('create-type command', () => {
it('has create-type command in Note group when onCreateType is provided', () => {
const onCreateType = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
const cmd = result.current.find(c => c.id === 'create-type')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('New Type')
expect(cmd!.group).toBe('Note')
expect(cmd!.enabled).toBe(true)
})
it('is disabled when onCreateType is not provided', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'create-type')
expect(cmd).toBeDefined()
expect(cmd!.enabled).toBe(false)
})
it('calls onCreateType when executed', () => {
const onCreateType = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
result.current.find(c => c.id === 'create-type')!.execute()
expect(onCreateType).toHaveBeenCalled()
})
it('has relevant keywords for discoverability', () => {
const onCreateType = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
const cmd = result.current.find(c => c.id === 'create-type')
expect(cmd!.keywords).toContain('new')
expect(cmd!.keywords).toContain('create')
expect(cmd!.keywords).toContain('type')
})
})
describe('type-aware commands', () => {
it('generates "New [Type]" commands from vault entries', () => {
const entries = [
@@ -437,6 +584,133 @@ describe('useCommandRegistry', () => {
const groups = new Set(result.current.map(c => c.group))
expect(groups).toContain('Appearance')
})
it('generates open-theme commands for each theme when onOpenTheme is provided', () => {
const onOpenTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
})))
const openDefault = result.current.find(c => c.id === 'open-theme-default')
const openDark = result.current.find(c => c.id === 'open-theme-dark')
expect(openDefault).toBeDefined()
expect(openDefault!.label).toBe('Edit Default Theme')
expect(openDefault!.group).toBe('Appearance')
expect(openDefault!.enabled).toBe(true)
expect(openDark).toBeDefined()
expect(openDark!.label).toBe('Edit Dark Theme')
})
it('omits open-theme commands when onOpenTheme is not provided', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default',
})))
expect(result.current.find(c => c.id === 'open-theme-default')).toBeUndefined()
expect(result.current.find(c => c.id === 'open-theme-dark')).toBeUndefined()
})
it('calls onOpenTheme with correct themeId when open-theme command executes', () => {
const onOpenTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
})))
result.current.find(c => c.id === 'open-theme-dark')!.execute()
expect(onOpenTheme).toHaveBeenCalledWith('dark')
})
it('open-theme command is always enabled regardless of active theme', () => {
const onOpenTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
})))
expect(result.current.find(c => c.id === 'open-theme-default')!.enabled).toBe(true)
})
})
describe('vault management commands', () => {
it('has remove-vault command in Settings group', () => {
const onRemoveActiveVault = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRemoveActiveVault, vaultCount: 2,
})))
const cmd = result.current.find(c => c.id === 'remove-vault')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('Remove Vault from List')
expect(cmd!.group).toBe('Settings')
expect(cmd!.enabled).toBe(true)
})
it('disables remove-vault when only one vault remains', () => {
const onRemoveActiveVault = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRemoveActiveVault, vaultCount: 1,
})))
expect(result.current.find(c => c.id === 'remove-vault')!.enabled).toBe(false)
})
it('disables remove-vault when onRemoveActiveVault is not provided', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ vaultCount: 3 })))
expect(result.current.find(c => c.id === 'remove-vault')!.enabled).toBe(false)
})
it('calls onRemoveActiveVault when remove-vault executes', () => {
const onRemoveActiveVault = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRemoveActiveVault, vaultCount: 2,
})))
result.current.find(c => c.id === 'remove-vault')!.execute()
expect(onRemoveActiveVault).toHaveBeenCalled()
})
it('has restore-getting-started command in Settings group', () => {
const onRestoreGettingStarted = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRestoreGettingStarted, isGettingStartedHidden: true,
})))
const cmd = result.current.find(c => c.id === 'restore-getting-started')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('Restore Getting Started Vault')
expect(cmd!.group).toBe('Settings')
expect(cmd!.enabled).toBe(true)
})
it('disables restore-getting-started when vault is not hidden', () => {
const onRestoreGettingStarted = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRestoreGettingStarted, isGettingStartedHidden: false,
})))
expect(result.current.find(c => c.id === 'restore-getting-started')!.enabled).toBe(false)
})
it('calls onRestoreGettingStarted when restore command executes', () => {
const onRestoreGettingStarted = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRestoreGettingStarted, isGettingStartedHidden: true,
})))
result.current.find(c => c.id === 'restore-getting-started')!.execute()
expect(onRestoreGettingStarted).toHaveBeenCalled()
})
it('remove-vault has relevant keywords for discoverability', () => {
const onRemoveActiveVault = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRemoveActiveVault, vaultCount: 2,
})))
const cmd = result.current.find(c => c.id === 'remove-vault')
expect(cmd!.keywords).toContain('vault')
expect(cmd!.keywords).toContain('remove')
expect(cmd!.keywords).toContain('disconnect')
})
it('restore-getting-started has relevant keywords for discoverability', () => {
const onRestoreGettingStarted = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
onRestoreGettingStarted, isGettingStartedHidden: true,
})))
const cmd = result.current.find(c => c.id === 'restore-getting-started')
expect(cmd!.keywords).toContain('vault')
expect(cmd!.keywords).toContain('restore')
expect(cmd!.keywords).toContain('demo')
})
})
})

View File

@@ -18,6 +18,7 @@ interface CommandRegistryConfig {
activeTabPath: string | null
entries: VaultEntry[]
modifiedCount: number
conflictCount?: number
onQuickOpen: () => void
onCreateNote: () => void
@@ -25,15 +26,21 @@ interface CommandRegistryConfig {
onSave: () => void
onOpenSettings: () => void
onOpenVault?: () => void
onCreateType?: () => void
onTrashNote: (path: string) => void
onRestoreNote: (path: string) => void
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
onResolveConflicts?: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleDiff?: () => void
onToggleRawEditor?: () => void
onToggleAIChat?: () => void
activeNoteModified: boolean
onCheckForUpdates?: () => void
isUpdating?: boolean
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
@@ -49,6 +56,11 @@ interface CommandRegistryConfig {
activeThemeId?: string | null
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
onOpenTheme?: (themeId: string) => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
isGettingStartedHidden?: boolean
vaultCount?: number
}
const PLURAL_OVERRIDES: Record<string, string> = {
@@ -105,8 +117,10 @@ export function buildTypeCommands(
export function buildViewCommands(
hasActiveNote: boolean,
activeNoteModified: boolean,
onSetViewMode: (mode: ViewMode) => void,
onToggleInspector: () => void,
onToggleDiff: (() => void) | undefined,
onToggleRawEditor: (() => void) | undefined,
onToggleAIChat: (() => void) | undefined,
zoomLevel: number,
@@ -118,9 +132,11 @@ export function buildViewCommands(
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
@@ -132,34 +148,52 @@ export function buildThemeCommands(
activeThemeId: string | null | undefined,
onSwitchTheme: ((themeId: string) => void) | undefined,
onCreateTheme: (() => void) | undefined,
onOpenTheme: ((themeId: string) => void) | undefined,
): CommandAction[] {
const switchCmds = (themes ?? []).map(t => ({
id: `switch-theme-${t.id}`,
label: `Switch to ${t.name} Theme`,
group: 'Appearance' as CommandGroup,
keywords: ['theme', 'appearance', 'color', t.name.toLowerCase()],
enabled: t.id !== activeThemeId,
execute: () => onSwitchTheme?.(t.id),
}))
const cmds: CommandAction[] = []
for (const t of (themes ?? [])) {
cmds.push({
id: `switch-theme-${t.id}`,
label: `Switch to ${t.name} Theme`,
group: 'Appearance' as CommandGroup,
keywords: ['theme', 'appearance', 'color', t.name.toLowerCase()],
enabled: t.id !== activeThemeId,
execute: () => onSwitchTheme?.(t.id),
})
if (onOpenTheme) {
cmds.push({
id: `open-theme-${t.id}`,
label: `Edit ${t.name} Theme`,
group: 'Appearance' as CommandGroup,
keywords: ['theme', 'edit', 'open', 'appearance', t.name.toLowerCase()],
enabled: true,
execute: () => onOpenTheme(t.id),
})
}
}
if (onCreateTheme) {
switchCmds.push({
cmds.push({
id: 'new-theme', label: 'New Theme', group: 'Appearance' as CommandGroup,
keywords: ['theme', 'create', 'appearance'], enabled: true, execute: onCreateTheme,
})
}
return switchCmds
return cmds
}
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
const {
activeTabPath, entries, modifiedCount,
activeTabPath, entries, modifiedCount, conflictCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
themes, activeThemeId, onSwitchTheme, onCreateTheme,
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
onCheckForUpdates, isUpdating,
onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
} = config
const hasActiveNote = activeTabPath !== null
@@ -187,6 +221,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
// Note actions (contextual)
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
{ id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } },
@@ -203,17 +238,21 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
// Git
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: (conflictCount ?? 0) > 0, execute: () => onResolveConflicts?.() },
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
// View
...buildViewCommands(hasActiveNote, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
...buildViewCommands(hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
// Appearance
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme),
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme),
// Settings
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: !isUpdating, execute: () => onCheckForUpdates?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
@@ -221,13 +260,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
return cmds
}, [
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, conflictCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates, isUpdating,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenVault,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
])
}

View File

@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useConflictResolver } from './useConflictResolver'
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
describe('useConflictResolver', () => {
const onResolved = vi.fn()
const onToast = vi.fn()
const onOpenFile = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockResolvedValue(undefined)
})
function renderResolver(files: string[] = ['note.md', 'plan.md']) {
const hook = renderHook(() =>
useConflictResolver({
vaultPath: '/vault',
onResolved,
onToast,
onOpenFile,
}),
)
// Initialize files
act(() => { hook.result.current.initFiles(files) })
return hook
}
it('initializes file states from conflict files', () => {
const { result } = renderResolver()
expect(result.current.fileStates).toHaveLength(2)
expect(result.current.fileStates[0]).toEqual({ file: 'note.md', resolution: null, resolving: false })
expect(result.current.fileStates[1]).toEqual({ file: 'plan.md', resolution: null, resolving: false })
expect(result.current.allResolved).toBe(false)
})
it('resolves a file with ours strategy', async () => {
const { result } = renderResolver()
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
expect(mockInvokeFn).toHaveBeenCalledWith('git_resolve_conflict', {
vaultPath: '/vault', file: 'note.md', strategy: 'ours',
})
expect(result.current.fileStates[0].resolution).toBe('ours')
expect(result.current.allResolved).toBe(false) // plan.md still unresolved
})
it('resolves a file with theirs strategy', async () => {
const { result } = renderResolver()
await act(async () => {
await result.current.resolveFile('plan.md', 'theirs')
})
expect(mockInvokeFn).toHaveBeenCalledWith('git_resolve_conflict', {
vaultPath: '/vault', file: 'plan.md', strategy: 'theirs',
})
expect(result.current.fileStates[1].resolution).toBe('theirs')
})
it('marks allResolved when all files are resolved', async () => {
const { result } = renderResolver()
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
expect(result.current.allResolved).toBe(false)
await act(async () => {
await result.current.resolveFile('plan.md', 'theirs')
})
expect(result.current.allResolved).toBe(true)
})
it('sets manual resolution when opening in editor', () => {
const { result } = renderResolver()
act(() => { result.current.openInEditor('note.md') })
expect(onOpenFile).toHaveBeenCalledWith('note.md')
expect(result.current.fileStates[0].resolution).toBe('manual')
})
it('commits resolution and calls onResolved', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'git_commit_conflict_resolution') return Promise.resolve('Committed')
return Promise.resolve(undefined)
})
const { result } = renderResolver(['note.md'])
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
await act(async () => {
await result.current.commitResolution()
})
expect(mockInvokeFn).toHaveBeenCalledWith('git_commit_conflict_resolution', { vaultPath: '/vault' })
expect(onResolved).toHaveBeenCalled()
expect(onToast).toHaveBeenCalledWith('Conflicts resolved — sync resumed')
})
it('does not commit when not all files are resolved', async () => {
const { result } = renderResolver()
await act(async () => {
await result.current.commitResolution()
})
expect(mockInvokeFn).not.toHaveBeenCalledWith('git_commit_conflict_resolution', expect.anything())
})
it('shows error when resolve fails', async () => {
mockInvokeFn.mockRejectedValueOnce(new Error('git checkout failed'))
const { result } = renderResolver()
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
expect(result.current.error).toContain('Failed to resolve note.md')
expect(result.current.fileStates[0].resolution).toBeNull()
})
it('shows error when commit fails', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'git_commit_conflict_resolution') return Promise.reject(new Error('user.email not set'))
return Promise.resolve(undefined)
})
const { result } = renderResolver(['note.md'])
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
await act(async () => {
await result.current.commitResolution()
})
expect(result.current.error).toContain('Commit failed')
expect(onResolved).not.toHaveBeenCalled()
})
it('resets state on initFiles', async () => {
const { result } = renderResolver(['note.md'])
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
expect(result.current.fileStates[0].resolution).toBe('ours')
act(() => { result.current.initFiles(['other.md']) })
expect(result.current.fileStates).toHaveLength(1)
expect(result.current.fileStates[0]).toEqual({ file: 'other.md', resolution: null, resolving: false })
expect(result.current.error).toBeNull()
})
})

View File

@@ -0,0 +1,96 @@
import { useCallback, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
}
export type FileResolution = 'ours' | 'theirs' | 'manual' | null
export interface ConflictFileState {
file: string
resolution: FileResolution
resolving: boolean
}
interface UseConflictResolverConfig {
vaultPath: string
onResolved: () => void
onToast: (msg: string) => void
onOpenFile: (relativePath: string) => void
}
export function useConflictResolver({
vaultPath,
onResolved,
onToast,
onOpenFile,
}: UseConflictResolverConfig) {
const [fileStates, setFileStates] = useState<ConflictFileState[]>([])
const [committing, setCommitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const initFiles = useCallback((files: string[]) => {
setFileStates(files.map(file => ({ file, resolution: null, resolving: false })))
setError(null)
setCommitting(false)
}, [])
const resolveFile = useCallback(async (file: string, strategy: 'ours' | 'theirs') => {
setFileStates(prev => prev.map(f =>
f.file === file ? { ...f, resolving: true } : f
))
setError(null)
try {
await tauriCall<void>('git_resolve_conflict', { vaultPath, file, strategy })
setFileStates(prev => prev.map(f =>
f.file === file ? { ...f, resolution: strategy, resolving: false } : f
))
} catch (err) {
setFileStates(prev => prev.map(f =>
f.file === file ? { ...f, resolving: false } : f
))
setError(`Failed to resolve ${file}: ${err}`)
}
}, [vaultPath])
const openInEditor = useCallback((file: string) => {
onOpenFile(file)
setFileStates(prev => prev.map(f =>
f.file === file ? { ...f, resolution: 'manual' } : f
))
}, [onOpenFile])
const allResolved = fileStates.length > 0 && fileStates.every(f => f.resolution !== null)
const anyResolving = fileStates.some(f => f.resolving)
const commitResolution = useCallback(async () => {
if (!allResolved || committing) return
setCommitting(true)
setError(null)
try {
await tauriCall<string>('git_commit_conflict_resolution', { vaultPath })
onResolved()
onToast('Conflicts resolved — sync resumed')
} catch (err) {
setError(`Commit failed: ${err}`)
} finally {
setCommitting(false)
}
}, [vaultPath, allResolved, committing, onResolved, onToast])
return {
fileStates,
committing,
error,
allResolved,
anyResolving,
initFiles,
resolveFile,
openInEditor,
commitResolution,
}
}

View File

@@ -8,6 +8,7 @@ export function useDialogs() {
const [showSettings, setShowSettings] = useState(false)
const [showGitHubVault, setShowGitHubVault] = useState(false)
const [showSearch, setShowSearch] = useState(false)
const [showConflictResolver, setShowConflictResolver] = useState(false)
const openCreateType = useCallback(() => setShowCreateTypeDialog(true), [])
const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), [])
@@ -22,6 +23,8 @@ export function useDialogs() {
const toggleAIChat = useCallback(() => setShowAIChat((c) => !c), [])
const openSearch = useCallback(() => setShowSearch(true), [])
const closeSearch = useCallback(() => setShowSearch(false), [])
const openConflictResolver = useCallback(() => setShowConflictResolver(true), [])
const closeConflictResolver = useCallback(() => setShowConflictResolver(false), [])
return {
showCreateTypeDialog, openCreateType, closeCreateType,
@@ -31,5 +34,6 @@ export function useDialogs() {
showSettings, openSettings, closeSettings,
showGitHubVault, openGitHubVault, closeGitHubVault,
showSearch, openSearch, closeSearch,
showConflictResolver, openConflictResolver, closeConflictResolver,
}
}

View File

@@ -110,6 +110,9 @@ describe('useEditorFocus', () => {
it('selects H1 text after timeout when editor not yet mounted', () => {
vi.useFakeTimers()
// Mock rAF synchronously so the deferred selectFirstHeading call inside doFocus
// runs immediately when requestAnimationFrame is invoked, keeping the test simple.
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const tiptap = makeTiptapMock(true)
const { editor } = setup(false, tiptap)
@@ -122,5 +125,37 @@ describe('useEditorFocus', () => {
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
vi.useRealTimers()
})
it('selection happens in second rAF (not first), allowing content swap to complete', () => {
// Verify the double-rAF contract: focus in rAF1, selection deferred to rAF2.
// This ensures the new note's blocks are applied (via queueMicrotask between frames)
// before selectFirstHeading runs.
const callbacks: FrameRequestCallback[] = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
callbacks.push(cb)
return callbacks.length
})
const tiptap = makeTiptapMock(true)
const { editor } = setup(true, tiptap)
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
// rAF 1 is scheduled (doFocus)
expect(callbacks.length).toBe(1)
callbacks[0](0)
// After rAF 1: editor focused, but selection NOT yet triggered
expect(editor.focus).toHaveBeenCalled()
expect(tiptap.chain).not.toHaveBeenCalled()
// rAF 2 is now scheduled (selectFirstHeading)
expect(callbacks.length).toBe(2)
callbacks[1](0)
// After rAF 2: heading is selected
expect(tiptap.chain).toHaveBeenCalled()
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
expect(tiptap._chainResult.run).toHaveBeenCalled()
})
})
})

View File

@@ -48,8 +48,20 @@ export function useEditorFocus(
const selectTitle = detail?.selectTitle ?? false
const doFocus = () => {
editor.focus()
if (selectTitle) selectFirstHeading(editor)
if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`)
if (!selectTitle) {
if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`)
return
}
// Defer selection to the next animation frame so the new note's content
// (applied via queueMicrotask inside a React effect triggered by the tab
// change) is in the document before we try to select the heading.
// Between two rAF callbacks, all pending macrotasks — including React's
// MessageChannel re-render and the subsequent queueMicrotask content swap
// — complete, so the heading block is guaranteed to exist by rAF 2.
requestAnimationFrame(() => {
selectFirstHeading(editor)
if (t0) console.debug(`[perf] createNote → focus+select: ${(performance.now() - t0).toFixed(1)}ms`)
})
}
if (editorMountedRef.current) {
requestAnimationFrame(doFocus)

View File

@@ -0,0 +1,32 @@
import { useCallback, useRef } from 'react'
import { useEditorSave } from './useEditorSave'
import { extractOutgoingLinks } from '../utils/wikilinks'
import type { VaultEntry } from '../types'
export function useEditorSaveWithLinks(config: {
updateContent: (path: string, content: string) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
setToastMessage: (msg: string | null) => void
onAfterSave: () => void
onNotePersisted?: (path: string) => void
}) {
const { updateContent, updateEntry } = config
const saveContent = useCallback((path: string, content: string) => {
updateContent(path, content)
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
}, [updateContent, updateEntry])
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
rawOnChange(path, content)
const links = extractOutgoingLinks(content)
const key = links.join('\0')
if (key !== prevLinksKeyRef.current) {
prevLinksKeyRef.current = key
updateEntry(path, { outgoingLinks: links })
}
}, [rawOnChange, updateEntry])
return { ...editor, handleContentChange }
}

View File

@@ -26,8 +26,9 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
})
@@ -238,4 +239,54 @@ describe('useEntryActions', () => {
expect(updateEntry).toHaveBeenCalledTimes(1)
})
})
describe('handleRenameSection', () => {
it('writes sidebar label frontmatter and updates entry in memory', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleRenameSection('Recipe', 'Recipes')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' })
})
it('trims whitespace before saving', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleRenameSection('Recipe', ' Dishes ')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Dishes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Dishes' })
})
it('deletes sidebar label when label is empty', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: 'Dishes' })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleRenameSection('Recipe', '')
})
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: null })
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
})
it('does nothing when type entry not found', async () => {
const { result } = setup([])
await act(async () => {
await result.current.handleRenameSection('NonExistent', 'Label')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
})
})
})

View File

@@ -68,5 +68,17 @@ export function useEntryActions({
updateEntry(typeEntry.path, { template: template || null })
}, [entries, handleUpdateFrontmatter, updateEntry])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate }
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
const trimmed = label.trim()
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
if (trimmed) {
await handleUpdateFrontmatter(typeEntry.path, 'sidebar label', trimmed)
} else {
await handleDeleteProperty(typeEntry.path, 'sidebar label')
}
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection }
}

View File

@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useIndexing } from './useIndexing'
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn().mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
})
}
if (cmd === 'start_indexing') return Promise.resolve(null)
if (cmd === 'trigger_incremental_index') return Promise.resolve(null)
return Promise.resolve(null)
}),
}))
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
describe('useIndexing', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts with idle progress', () => {
const { result } = renderHook(() => useIndexing('/vault'))
expect(result.current.progress.phase).toBe('idle')
expect(result.current.progress.done).toBe(false)
})
it('checks index status on mount', async () => {
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('get_index_status', { vaultPath: '/vault' })
})
})
it('does not start indexing when collection exists and no pending embeds', async () => {
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('get_index_status', { vaultPath: '/vault' })
})
expect(mockInvoke).not.toHaveBeenCalledWith('start_indexing', expect.anything())
})
it('starts indexing when collection is missing', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
return Promise.resolve(null)
})
const { result } = renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
expect(result.current.progress.phase).not.toBe('idle')
})
it('starts indexing when qmd is not installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
return Promise.resolve(null)
})
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
})
it('starts indexing when pending embeds exist', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 20,
})
}
return Promise.resolve(null)
})
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
})
it('triggerIncrementalIndex calls the command', async () => {
const { result } = renderHook(() => useIndexing('/vault'))
await act(async () => {
await result.current.triggerIncrementalIndex()
})
expect(mockInvoke).toHaveBeenCalledWith('trigger_incremental_index', { vaultPath: '/vault' })
})
it('handles index status check failure gracefully', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') return Promise.reject(new Error('network error'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useIndexing('/vault'))
// Should remain idle — not crash
await waitFor(() => {
expect(result.current.progress.phase).toBe('idle')
})
})
it('sets error phase when start_indexing fails', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
if (cmd === 'start_indexing') return Promise.reject(new Error('install failed'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(result.current.progress.phase).toBe('error')
})
expect(result.current.progress.error).toContain('install failed')
})
})

116
src/hooks/useIndexing.ts Normal file
View File

@@ -0,0 +1,116 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export interface IndexingProgress {
phase: 'idle' | 'installing' | 'scanning' | 'embedding' | 'complete' | 'error'
current: number
total: number
done: boolean
error: string | null
}
interface IndexStatus {
available: boolean
qmd_installed: boolean
collection_exists: boolean
indexed_count: number
embedded_count: number
pending_embed: number
}
const IDLE: IndexingProgress = { phase: 'idle', current: 0, total: 0, done: false, error: null }
function invokeCmd<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
}
export function useIndexing(vaultPath: string) {
const [progress, setProgress] = useState<IndexingProgress>(IDLE)
const indexingRef = useRef(false)
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
// Listen for progress events from Rust
useEffect(() => {
if (!isTauri()) return
let cleanup: (() => void) | null = null
import('@tauri-apps/api/event').then(({ listen }) => {
const unlisten = listen<IndexingProgress>('indexing-progress', (event) => {
setProgress(event.payload)
if (event.payload.done) {
indexingRef.current = false
}
})
cleanup = () => { unlisten.then(fn => fn()) }
})
return () => { cleanup?.() }
}, [])
// Check index status and auto-trigger indexing on vault open
useEffect(() => {
if (!vaultPath) return
let cancelled = false
async function checkAndIndex() {
try {
const status = await invokeCmd<IndexStatus>('get_index_status', { vaultPath })
if (cancelled) return
// If qmd not installed or no collection or pending embeds, trigger indexing
const needsIndexing = !status.qmd_installed || !status.collection_exists || status.pending_embed > 0
if (needsIndexing && !indexingRef.current) {
indexingRef.current = true
setProgress({
phase: 'scanning',
current: 0,
total: status.indexed_count,
done: false,
error: null,
})
// Fire and forget — progress updates come via events
invokeCmd('start_indexing', { vaultPath }).catch((err) => {
if (!cancelled) {
setProgress({
phase: 'error',
current: 0,
total: 0,
done: true,
error: String(err),
})
indexingRef.current = false
}
})
}
} catch {
// get_index_status failed — likely qmd not available, non-fatal
}
}
checkAndIndex()
return () => { cancelled = true }
}, [vaultPath])
// Auto-dismiss the "complete" status after 5 seconds
useEffect(() => {
if (progress.phase === 'complete') {
const timer = setTimeout(() => setProgress(IDLE), 5000)
return () => clearTimeout(timer)
}
}, [progress.phase])
const triggerIncrementalIndex = useCallback(async () => {
if (indexingRef.current) return
try {
await invokeCmd('trigger_incremental_index', { vaultPath: vaultPathRef.current })
} catch {
// Incremental update failure is non-fatal
}
}, [])
return { progress, triggerIncrementalIndex }
}

View File

@@ -30,8 +30,9 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
})

View File

@@ -0,0 +1,52 @@
import { useEffect } from 'react'
/**
* Registers mouse button 3/4 (back/forward) and macOS trackpad two-finger
* horizontal swipe gestures for navigation.
*/
export function useNavigationGestures({
onGoBack,
onGoForward,
}: {
onGoBack: () => void
onGoForward: () => void
}) {
useEffect(() => {
const handleMouseBack = (e: MouseEvent) => {
if (e.button === 3) { e.preventDefault(); onGoBack() }
if (e.button === 4) { e.preventDefault(); onGoForward() }
}
window.addEventListener('mouseup', handleMouseBack)
// Trackpad swipe: accumulate horizontal wheel delta and trigger on threshold
let accumulatedDeltaX = 0
let resetTimer: ReturnType<typeof setTimeout> | null = null
const SWIPE_THRESHOLD = 120
const handleWheel = (e: WheelEvent) => {
// Only handle horizontal-dominant gestures (trackpad swipe)
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
if (e.ctrlKey || e.metaKey) return // ignore pinch-zoom
accumulatedDeltaX += e.deltaX
if (resetTimer) clearTimeout(resetTimer)
resetTimer = setTimeout(() => { accumulatedDeltaX = 0 }, 300)
if (accumulatedDeltaX > SWIPE_THRESHOLD) {
accumulatedDeltaX = 0
onGoForward()
} else if (accumulatedDeltaX < -SWIPE_THRESHOLD) {
accumulatedDeltaX = 0
onGoBack()
}
}
window.addEventListener('wheel', handleWheel, { passive: true })
return () => {
window.removeEventListener('mouseup', handleMouseBack)
window.removeEventListener('wheel', handleWheel)
if (resetTimer) clearTimeout(resetTimer)
}
}, [onGoBack, onGoForward])
}

View File

@@ -59,7 +59,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
color: null,
order: null,
outgoingLinks: [],
template: null,
template: null, sort: null,
...overrides,
})

View File

@@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
aliases: [], belongsTo: [], relatedTo: [],
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, properties: {},
}
}
@@ -148,7 +148,7 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
template: { template: null },
template: { template: null }, sort: { sort: null },
}
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
@@ -166,6 +166,7 @@ export function frontmatterToEntryPatch(
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
order: { order: typeof value === 'number' ? value : null },
template: { template: str },
sort: { sort: str },
}
return updates[k] ?? {}
}

View File

@@ -22,7 +22,7 @@ function makeEntry(path: string, title: string): VaultEntry {
fileSize: 100,
color: null,
icon: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
relationships: {},
}

View File

@@ -20,6 +20,9 @@ vi.mock('../mock-tauri', () => ({
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('./useVaultSwitcher', () => ({
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
@@ -35,7 +38,7 @@ describe('useOnboarding', () => {
it('transitions to ready when vault exists', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return true
return null
})
@@ -50,7 +53,7 @@ describe('useOnboarding', () => {
it('shows welcome screen when vault does not exist', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -60,14 +63,14 @@ describe('useOnboarding', () => {
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Laputa' })
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Getting Started' })
})
it('shows vault-missing when previously dismissed and vault gone', async () => {
localStorage.setItem('laputa_welcome_dismissed', '1')
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -80,15 +83,15 @@ describe('useOnboarding', () => {
expect(result.current.state).toEqual({
status: 'vault-missing',
vaultPath: '/vault/deleted',
defaultPath: '/mock/Documents/Laputa',
defaultPath: '/mock/Documents/Getting Started',
})
})
it('handleCreateVault creates vault and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Laputa'
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Getting Started'
return null
})
@@ -102,13 +105,13 @@ describe('useOnboarding', () => {
await result.current.handleCreateVault()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Laputa' })
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
})
it('handleCreateVault sets error on failure', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') throw 'Permission denied'
return null
@@ -130,7 +133,7 @@ describe('useOnboarding', () => {
it('handleOpenFolder opens folder picker and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -152,7 +155,7 @@ describe('useOnboarding', () => {
it('handleOpenFolder does nothing when picker is cancelled', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -173,7 +176,7 @@ describe('useOnboarding', () => {
it('handleDismiss marks dismissed and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})

View File

@@ -32,7 +32,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -1,50 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import type { ThemeFile, VaultSettings } from '../types'
import type { VaultEntry } from '../types'
const mockThemes: ThemeFile[] = [
{
id: 'default', name: 'Default', description: 'Clean default theme',
colors: { background: '#FFFFFF', foreground: '#1A1A2E', primary: '#6366F1', border: '#E2E8F0', 'sidebar-background': '#F8FAFC' },
typography: { 'font-family': 'Inter, sans-serif' },
spacing: { 'sidebar-width': '240px' },
},
{
id: 'dark', name: 'Dark', description: 'Dark theme',
colors: { background: '#0F0F23', foreground: '#E2E8F0', primary: '#818CF8', border: '#1E293B' },
typography: { 'font-family': 'Inter, sans-serif' },
spacing: {},
},
]
const THEME_PATH_DEFAULT = '/vault/theme/default.md'
const THEME_PATH_DARK = '/vault/theme/dark.md'
const mockSettings: VaultSettings = { theme: 'default' }
const DEFAULT_THEME_CONTENT = `---
Is A: Theme
Description: Light theme
background: "#FFFFFF"
foreground: "#37352F"
primary: "#155DFF"
sidebar: "#F7F6F3"
text-primary: "#37352F"
---
const mockInvokeFn = vi.fn(async (cmd: string) => {
if (cmd === 'list_themes') return mockThemes
if (cmd === 'get_vault_settings') return mockSettings
# Default Theme
`
const DARK_THEME_CONTENT = `---
Is A: Theme
Description: Dark theme
background: "#0f0f1a"
foreground: "#e0e0e0"
primary: "#155DFF"
sidebar: "#1a1a2e"
text-primary: "#e0e0e0"
---
# Dark Theme
`
function makeThemeEntry(path: string, title: string): VaultEntry {
return {
path,
filename: path.split('/').pop()!,
title,
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: null,
createdAt: null,
fileSize: 0,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}
}
const defaultEntry = makeThemeEntry(THEME_PATH_DEFAULT, 'Default Theme')
const darkEntry = makeThemeEntry(THEME_PATH_DARK, 'Dark Theme')
const mockInvokeFn = vi.fn(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT }
if (cmd === 'get_note_content') {
const path = args?.path as string | undefined
if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT
if (path === THEME_PATH_DARK) return DARK_THEME_CONTENT
return ''
}
if (cmd === 'set_active_theme') return null
if (cmd === 'create_theme') return 'new-theme-id'
if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md'
return null
})
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
// Must import after mocks
const { useThemeManager, isColorDark } = await import('./useThemeManager')
const { useThemeManager, extractCssVars, isColorDark } = await import('./useThemeManager')
describe('extractCssVars', () => {
it('extracts color variables from frontmatter', () => {
const vars = extractCssVars(DEFAULT_THEME_CONTENT)
expect(vars['--background']).toBe('#FFFFFF')
expect(vars['--foreground']).toBe('#37352F')
expect(vars['--primary']).toBe('#155DFF')
})
it('excludes metadata keys', () => {
const vars = extractCssVars(DEFAULT_THEME_CONTENT)
expect('--Is A' in vars).toBe(false)
expect('--Description' in vars).toBe(false)
})
})
describe('isColorDark', () => {
it('identifies dark colors', () => {
expect(isColorDark('#000000')).toBe(true)
expect(isColorDark('#0F0F23')).toBe(true)
expect(isColorDark('#1a1a2e')).toBe(true)
expect(isColorDark('#0f0f1a')).toBe(true)
expect(isColorDark('#1a1a2e')).toBe(true)
})
it('identifies light colors', () => {
@@ -52,305 +113,369 @@ describe('isColorDark', () => {
expect(isColorDark('#F7F6F3')).toBe(false)
expect(isColorDark('#E0E0E0')).toBe(false)
})
it('returns false for invalid hex', () => {
expect(isColorDark('')).toBe(false)
expect(isColorDark('#abc')).toBe(false)
expect(isColorDark('red')).toBe(false)
})
})
describe('useThemeManager', () => {
const entries = [defaultEntry, darkEntry]
const allContent: Record<string, string> = {}
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'list_themes') return mockThemes
if (cmd === 'get_vault_settings') return mockSettings
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT }
if (cmd === 'get_note_content') {
const path = args?.path as string | undefined
if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT
if (path === THEME_PATH_DARK) return DARK_THEME_CONTENT
return ''
}
if (cmd === 'set_active_theme') return null
if (cmd === 'create_theme') return 'new-theme-id'
if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md'
return null
})
// Clear any theme CSS properties from previous tests
const root = document.documentElement
root.style.cssText = ''
document.documentElement.style.cssText = ''
})
it('loads themes and active theme on mount', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
it('builds themes list from vault entries with isA === Theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
expect(result.current.activeThemeId).toBe('default')
expect(result.current.activeTheme?.name).toBe('Default')
expect(result.current.themes[0].name).toBe('Default Theme')
expect(result.current.themes[0].id).toBe(THEME_PATH_DEFAULT)
})
it('loads active theme from vault settings on mount', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
expect(result.current.activeTheme?.name).toBe('Default Theme')
})
it('applies CSS vars from theme note content', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--foreground')).toBe('#37352F')
expect(root.style.getPropertyValue('--primary')).toBe('#155DFF')
})
it('returns empty state when vaultPath is null', async () => {
const { result } = renderHook(() => useThemeManager(null))
// Give it time to settle — should remain empty
const { result } = renderHook(() =>
useThemeManager(null, entries, allContent)
)
await new Promise(r => setTimeout(r, 50))
expect(result.current.themes).toHaveLength(0)
expect(result.current.activeThemeId).toBeNull()
expect(result.current.activeTheme).toBeNull()
expect(mockInvokeFn).not.toHaveBeenCalled()
})
it('applies CSS custom properties for active theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
it('excludes trashed entries from themes list', async () => {
const trashedEntry = { ...darkEntry, trashed: true }
const { result } = renderHook(() =>
useThemeManager('/vault', [defaultEntry, trashedEntry], allContent)
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
expect(result.current.themes).toHaveLength(1)
})
const root = document.documentElement
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--foreground')).toBe('#1A1A2E')
expect(root.style.getPropertyValue('--primary')).toBe('#6366F1')
expect(root.style.getPropertyValue('--theme-background')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--theme-font-family')).toBe('Inter, sans-serif')
expect(root.style.getPropertyValue('--theme-sidebar-width')).toBe('240px')
})
it('maps sidebar-background to --sidebar', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#F8FAFC')
expect(result.current.themes[0].name).toBe('Default Theme')
})
it('switchTheme calls set_active_theme and updates activeThemeId', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
await act(async () => {
await result.current.switchTheme(THEME_PATH_DARK)
})
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', {
vaultPath: '/vault', themeId: THEME_PATH_DARK,
})
expect(result.current.activeThemeId).toBe(THEME_PATH_DARK)
})
it('clears old CSS vars and applies new theme on switch', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
})
await act(async () => {
await result.current.switchTheme('dark')
await result.current.switchTheme(THEME_PATH_DARK)
})
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', { vaultPath: '/vault', themeId: 'dark' })
expect(result.current.activeThemeId).toBe('dark')
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#0f0f1a')
})
})
it('clears old theme CSS and applies new theme on switch', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme?.id).toBe('default')
})
const root = document.documentElement
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
it('createTheme calls create_vault_theme and switches to new theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
let newPath = ''
await act(async () => {
await result.current.switchTheme('dark')
newPath = await result.current.createTheme('My Theme')
})
await waitFor(() => {
expect(root.style.getPropertyValue('--background')).toBe('#0F0F23')
expect(newPath).toBe('/vault/theme/untitled.md')
expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', {
vaultPath: '/vault', name: 'My Theme',
})
expect(root.style.getPropertyValue('--foreground')).toBe('#E2E8F0')
expect(result.current.activeThemeId).toBe('/vault/theme/untitled.md')
})
it('createTheme calls create_theme and reloads themes', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
let newId = ''
await act(async () => {
newId = await result.current.createTheme('default')
})
expect(newId).toBe('new-theme-id')
expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vaultPath: '/vault', sourceId: 'default' })
// Should reload after creation
const listCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes')
expect(listCalls.length).toBeGreaterThanOrEqual(2)
})
it('createTheme passes null source_id when no sourceId provided', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
it('createTheme passes null name when none provided', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
await act(async () => {
await result.current.createTheme()
})
expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vaultPath: '/vault', sourceId: null })
expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', {
vaultPath: '/vault', name: null,
})
})
it('falls back when active theme is trashed', async () => {
const { result, rerender } = renderHook(
({ ents }) => useThemeManager('/vault', ents, allContent),
{ initialProps: { ents: entries } },
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
const trashedDefault = { ...defaultEntry, trashed: true }
rerender({ ents: [trashedDefault, darkEntry] })
await waitFor(() => {
expect(result.current.activeThemeId).toBeNull()
})
// CSS vars are cleared from tracked applied vars — DOM state depends on prior apply
})
it('handles load failure gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockRejectedValue(new Error('disk error'))
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith('Failed to load themes:', expect.any(Error))
})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await new Promise(r => setTimeout(r, 50))
expect(result.current.themes).toHaveLength(0)
expect(result.current.activeThemeId).toBeNull()
warnSpy.mockRestore()
})
it('handles switchTheme failure gracefully', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
mockInvokeFn.mockRejectedValueOnce(new Error('permission denied'))
await act(async () => {
await result.current.switchTheme('dark')
await result.current.switchTheme(THEME_PATH_DARK)
})
// Should not have changed the active theme
expect(result.current.activeThemeId).toBe('default')
expect(errorSpy).toHaveBeenCalledWith('Failed to switch theme:', expect.any(Error))
errorSpy.mockRestore()
})
it('handles createTheme failure gracefully', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
mockInvokeFn.mockRejectedValueOnce(new Error('write error'))
let newId = ''
await act(async () => {
newId = await result.current.createTheme('default')
})
expect(newId).toBe('')
expect(errorSpy).toHaveBeenCalledWith('Failed to create theme:', expect.any(Error))
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
errorSpy.mockRestore()
})
it('switchTheme is a no-op when vaultPath is null', async () => {
const { result } = renderHook(() => useThemeManager(null))
const { result } = renderHook(() =>
useThemeManager(null, entries, allContent)
)
await act(async () => {
await result.current.switchTheme('dark')
await result.current.switchTheme(THEME_PATH_DARK)
})
expect(mockInvokeFn).not.toHaveBeenCalledWith('set_active_theme', expect.anything())
})
it('createTheme returns empty string when vaultPath is null', async () => {
const { result } = renderHook(() => useThemeManager(null))
let newId = ''
const { result } = renderHook(() =>
useThemeManager(null, entries, allContent)
)
let newPath = ''
await act(async () => {
newId = await result.current.createTheme()
newPath = await result.current.createTheme()
})
expect(newId).toBe('')
expect(newPath).toBe('')
})
it('isDark is false for light theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme?.id).toBe('default')
})
expect(result.current.isDark).toBe(false)
})
it('isDark is true for dark theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.switchTheme('dark')
})
expect(result.current.isDark).toBe(true)
})
it('isDark is false when no active theme', async () => {
const { result } = renderHook(() => useThemeManager(null))
expect(result.current.isDark).toBe(false)
})
it('derives app-specific CSS variables from theme colors', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
it('re-applies theme when active content changes in allContent', async () => {
const { result, rerender } = renderHook(
({ content }) => useThemeManager('/vault', entries, content),
{ initialProps: { content: allContent } },
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--text-primary')).toBe('#1A1A2E')
expect(root.style.getPropertyValue('--text-heading')).toBe('#1A1A2E')
expect(root.style.getPropertyValue('--border-primary')).toBe('#E2E8F0')
const newContent = {
[THEME_PATH_DEFAULT]: `---\nIs A: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`,
}
rerender({ content: newContent })
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000')
})
})
it('sets color-scheme and data-theme-mode for dark theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
it('reloadThemes re-reads vault settings', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
await act(async () => {
await result.current.switchTheme('dark')
})
const root = document.documentElement
await waitFor(() => {
expect(root.style.getPropertyValue('color-scheme')).toBe('dark')
})
expect(root.dataset.themeMode).toBe('dark')
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
expect(root.style.getPropertyValue('--text-primary')).toBe('#E2E8F0')
})
it('sets color-scheme to light for light theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
expect(root.dataset.themeMode).toBe('light')
})
it('updates derived variables when switching between themes', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.switchTheme('dark')
})
const root = document.documentElement
await waitFor(() => {
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
})
await act(async () => {
await result.current.switchTheme('default')
})
await waitFor(() => {
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
})
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
expect(root.dataset.themeMode).toBe('light')
})
it('reloadThemes re-fetches theme list', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
const initialListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
const initialCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length
await act(async () => {
await result.current.reloadThemes()
})
const afterListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
expect(afterListCalls).toBe(initialListCalls + 1)
const afterCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length
expect(afterCalls).toBe(initialCalls + 1)
})
it('clears stale theme ID that does not match any known theme', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_vault_settings') return { theme: 'untitled-2' }
if (cmd === 'set_active_theme') return null
return null
})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
// Stale ID "untitled-2" doesn't match any theme path — should be cleared
await waitFor(() => {
expect(result.current.activeThemeId).toBeNull()
})
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', {
vaultPath: '/vault', themeId: null,
})
})
it('sets color-scheme to light for light theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
expect(root.dataset.themeMode).toBe('light')
})
it('sets color-scheme to dark for dark theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.switchTheme(THEME_PATH_DARK)
})
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark')
})
expect(document.documentElement.dataset.themeMode).toBe('dark')
})
it('populates theme colors from allContent when available', async () => {
const contentWithColors = {
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
[THEME_PATH_DARK]: DARK_THEME_CONTENT,
}
const { result } = renderHook(() =>
useThemeManager('/vault', entries, contentWithColors)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
const defaultTheme = result.current.themes.find(t => t.id === THEME_PATH_DEFAULT)
expect(defaultTheme?.colors.background).toBe('#FFFFFF')
expect(defaultTheme?.colors.primary).toBe('#155DFF')
const darkTheme = result.current.themes.find(t => t.id === THEME_PATH_DARK)
expect(darkTheme?.colors.background).toBe('#0f0f1a')
})
it('isDark detects dark theme from cached content', async () => {
const contentWithColors = {
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
[THEME_PATH_DARK]: DARK_THEME_CONTENT,
}
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DARK }
if (cmd === 'set_active_theme') return null
if (cmd === 'get_note_content') return DARK_THEME_CONTENT
return null
})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, contentWithColors)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DARK)
})
await waitFor(() => {
expect(result.current.isDark).toBe(true)
})
})
it('isDark is false for light theme', async () => {
const contentWithColors = {
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
}
const { result } = renderHook(() =>
useThemeManager('/vault', entries, contentWithColors)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
// Light theme isDark should be false (default state is false, so this is stable)
expect(result.current.isDark).toBe(false)
})
})

View File

@@ -1,157 +1,116 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { ThemeFile, VaultSettings } from '../types'
import { parseFrontmatter } from '../utils/frontmatter'
import type { ThemeFile, VaultEntry, VaultSettings } from '../types'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
// --- Color utilities for theme variable derivation ---
/** Frontmatter keys that are metadata — not CSS custom properties. */
const NON_THEME_KEYS = new Set([
'Is A', 'type', 'is_a', 'is a',
'Name', 'name', 'title', 'Title',
'Description', 'description',
'Archived', 'archived',
'Trashed', 'trashed',
'Trashed at', 'trashed at', 'trashed_at',
'Created at', 'created at', 'created_at',
'Created time', 'created_time',
'Owner', 'owner',
'Status', 'status',
'Cadence', 'cadence',
'aliases',
'Belongs to', 'belongs_to', 'belongs to',
'Related to', 'related_to', 'related to',
])
function parseHex(hex: string): [number, number, number] {
const h = hex.replace('#', '')
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
/** Extract CSS custom properties from a theme note's frontmatter content. */
export function extractCssVars(content: string): Record<string, string> {
const fm = parseFrontmatter(content)
const vars: Record<string, string> = {}
for (const [key, value] of Object.entries(fm)) {
if (NON_THEME_KEYS.has(key)) continue
if (typeof value === 'string' && value) {
vars[`--${key}`] = value
} else if (typeof value === 'number') {
vars[`--${key}`] = String(value)
}
}
return vars
}
function toHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(c => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')
}
/** Blend two hex colors. ratio=0 → color1, ratio=1 → color2. */
function mixColors(hex1: string, hex2: string, ratio: number): string {
const [r1, g1, b1] = parseHex(hex1)
const [r2, g2, b2] = parseHex(hex2)
return toHex(r1 + (r2 - r1) * ratio, g1 + (g2 - g1) * ratio, b1 + (b2 - b1) * ratio)
/** Extract bare colors (without -- prefix) for ThemeFile.colors from content. */
function extractColorsFromContent(content: string): Record<string, string> {
const fm = parseFrontmatter(content)
const colors: Record<string, string> = {}
for (const [key, value] of Object.entries(fm)) {
if (NON_THEME_KEYS.has(key)) continue
if (typeof value === 'string' && value.startsWith('#')) {
colors[key] = value
}
}
return colors
}
/** Check if a hex color is perceptually dark (luminance < 0.5). */
export function isColorDark(hex: string): boolean {
const [r, g, b] = parseHex(hex)
if (!hex.startsWith('#') || hex.length < 7) return false
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
}
// Variables derived from theme core colors (not present in theme.colors directly)
const DERIVED_VAR_NAMES = [
'bg-primary', 'bg-sidebar', 'bg-card', 'bg-hover', 'bg-hover-subtle', 'bg-selected',
'bg-input', 'bg-button', 'bg-dialog',
'text-primary', 'text-heading', 'text-secondary', 'text-tertiary', 'text-muted', 'text-faint',
'border-primary', 'border-subtle', 'border-input', 'border-dialog',
'link-color', 'link-hover',
// shadcn variables that may not be in the theme
'card', 'card-foreground', 'popover', 'popover-foreground',
'secondary', 'secondary-foreground', 'muted-foreground',
'accent', 'accent-foreground', 'input', 'ring',
'sidebar-foreground', 'sidebar-primary', 'sidebar-primary-foreground',
'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring',
]
/** Derive app-specific and missing shadcn CSS variables from core theme colors. */
function deriveThemeVariables(root: HTMLElement, colors: Record<string, string>): void {
const bg = colors.background
const fg = colors.foreground
if (!bg || !fg) return
const isDark = isColorDark(bg)
root.style.setProperty('color-scheme', isDark ? 'dark' : 'light')
root.dataset.themeMode = isDark ? 'dark' : 'light'
const primary = colors.primary ?? (isDark ? '#5C9CFF' : '#155DFF')
const border = colors.border ?? mixColors(bg, fg, isDark ? 0.15 : 0.08)
const muted = colors.muted ?? mixColors(bg, fg, isDark ? 0.08 : 0.05)
const sidebarBg = colors['sidebar-background'] ?? mixColors(bg, fg, 0.04)
// App-specific variables
root.style.setProperty('--bg-primary', bg)
root.style.setProperty('--bg-sidebar', sidebarBg)
root.style.setProperty('--bg-card', mixColors(bg, fg, 0.03))
root.style.setProperty('--bg-hover', mixColors(bg, fg, 0.1))
root.style.setProperty('--bg-hover-subtle', muted)
root.style.setProperty('--bg-selected', `${primary}25`)
root.style.setProperty('--bg-input', bg)
root.style.setProperty('--bg-button', mixColors(bg, fg, 0.1))
root.style.setProperty('--bg-dialog', mixColors(bg, fg, 0.02))
root.style.setProperty('--text-primary', fg)
root.style.setProperty('--text-heading', fg)
root.style.setProperty('--text-secondary', mixColors(fg, bg, 0.25))
root.style.setProperty('--text-tertiary', mixColors(fg, bg, 0.35))
root.style.setProperty('--text-muted', mixColors(fg, bg, 0.5))
root.style.setProperty('--text-faint', mixColors(fg, bg, 0.6))
root.style.setProperty('--border-primary', border)
root.style.setProperty('--border-subtle', border)
root.style.setProperty('--border-input', border)
root.style.setProperty('--border-dialog', border)
root.style.setProperty('--link-color', primary)
root.style.setProperty('--link-hover', mixColors(primary, fg, 0.2))
// Shadcn variables — only set if not already provided by the theme
const setIfMissing = (name: string, value: string) => {
if (!(name in colors)) root.style.setProperty(`--${name}`, value)
}
setIfMissing('card', mixColors(bg, fg, 0.03))
setIfMissing('card-foreground', fg)
setIfMissing('popover', mixColors(bg, fg, 0.04))
setIfMissing('popover-foreground', fg)
setIfMissing('secondary', mixColors(bg, fg, 0.08))
setIfMissing('secondary-foreground', fg)
setIfMissing('muted-foreground', mixColors(fg, bg, 0.3))
setIfMissing('accent', mixColors(bg, fg, 0.08))
setIfMissing('accent-foreground', fg)
setIfMissing('input', border)
setIfMissing('ring', primary)
setIfMissing('sidebar-foreground', fg)
setIfMissing('sidebar-accent', mixColors(sidebarBg, fg, 0.1))
setIfMissing('sidebar-accent-foreground', fg)
setIfMissing('sidebar-border', border)
setIfMissing('sidebar-primary', primary)
setIfMissing('sidebar-primary-foreground', '#FFFFFF')
setIfMissing('sidebar-ring', primary)
/** Update color-scheme and data-theme-mode on document root based on --background. */
function updateColorScheme(vars: Record<string, string>): void {
const bg = vars['--background']
if (!bg) return
const dark = isColorDark(bg)
const root = document.documentElement
root.style.setProperty('color-scheme', dark ? 'dark' : 'light')
root.dataset.themeMode = dark ? 'dark' : 'light'
}
function clearDerivedVariables(root: HTMLElement): void {
for (const name of DERIVED_VAR_NAMES) {
root.style.removeProperty(`--${name}`)
}
function clearColorScheme(): void {
const root = document.documentElement
root.style.removeProperty('color-scheme')
delete root.dataset.themeMode
}
/** Map theme colors/typography/spacing to CSS custom properties on :root. */
function applyThemeToDom(theme: ThemeFile): void {
function applyVarsToDom(vars: Record<string, string>): void {
const root = document.documentElement
for (const [key, value] of Object.entries(theme.colors)) {
root.style.setProperty(`--theme-${key}`, value)
root.style.setProperty(`--${key}`, value)
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value)
}
for (const [key, value] of Object.entries(theme.typography)) {
root.style.setProperty(`--theme-${key}`, value)
}
for (const [key, value] of Object.entries(theme.spacing)) {
root.style.setProperty(`--theme-${key}`, value)
}
if (theme.colors['sidebar-background']) {
root.style.setProperty('--sidebar', theme.colors['sidebar-background'])
}
deriveThemeVariables(root, theme.colors)
updateColorScheme(vars)
}
function clearThemeFromDom(theme: ThemeFile): void {
function clearVarsFromDom(vars: Record<string, string>): void {
const root = document.documentElement
for (const key of Object.keys(theme.colors)) {
root.style.removeProperty(`--theme-${key}`)
root.style.removeProperty(`--${key}`)
for (const key of Object.keys(vars)) {
root.style.removeProperty(key)
}
for (const key of Object.keys(theme.typography)) {
root.style.removeProperty(`--theme-${key}`)
clearColorScheme()
}
/** Build a ThemeFile descriptor from a vault entry, enriched with content colors. */
function entryToThemeFile(entry: VaultEntry, content: string | undefined): ThemeFile {
return {
id: entry.path,
name: entry.title,
description: '',
path: entry.path,
colors: content ? extractColorsFromContent(content) : {},
typography: {},
spacing: {},
}
for (const key of Object.keys(theme.spacing)) {
root.style.removeProperty(`--theme-${key}`)
}
root.style.removeProperty('--sidebar')
clearDerivedVariables(root)
}
/** True when a theme entry should no longer be applied (trashed or archived). */
function isEntryRemoved(entry: VaultEntry): boolean {
return entry.trashed || entry.archived
}
export interface ThemeManager {
@@ -160,81 +119,149 @@ export interface ThemeManager {
activeTheme: ThemeFile | null
isDark: boolean
switchTheme: (themeId: string) => Promise<void>
createTheme: (sourceId?: string) => Promise<string>
createTheme: (name?: string) => Promise<string>
reloadThemes: () => Promise<void>
}
/** Sync CSS custom properties: clear old theme, apply new one. */
function syncThemeDom(
prevRef: React.MutableRefObject<ThemeFile | null>,
theme: ThemeFile | null,
): void {
if (prevRef.current) clearThemeFromDom(prevRef.current)
if (theme) {
applyThemeToDom(theme)
prevRef.current = theme
} else {
prevRef.current = null
}
}
export function useThemeManager(vaultPath: string | null): ThemeManager {
const [themes, setThemes] = useState<ThemeFile[]>([])
/** Manages loading and persisting the active theme path from vault settings. */
function useThemeSetting(vaultPath: string | null) {
const [activeThemeId, setActiveThemeId] = useState<string | null>(null)
const prevThemeRef = useRef<ThemeFile | null>(null)
const activeTheme = themes.find(t => t.id === activeThemeId) ?? null
const isDark = activeTheme?.colors.background ? isColorDark(activeTheme.colors.background) : false
const loadThemes = useCallback(async () => {
const load = useCallback(async () => {
if (!vaultPath) return
try {
const [themeList, settings] = await Promise.all([
tauriCall<ThemeFile[]>('list_themes', { vaultPath }),
tauriCall<VaultSettings>('get_vault_settings', { vaultPath }),
])
setThemes(themeList)
setActiveThemeId(settings.theme)
} catch (err) {
console.warn('Failed to load themes:', err)
}
const s = await tauriCall<VaultSettings>('get_vault_settings', { vaultPath })
setActiveThemeId(s.theme)
} catch { /* no settings file — fine, no active theme */ }
}, [vaultPath])
useEffect(() => { loadThemes() }, [loadThemes]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
useEffect(() => { syncThemeDom(prevThemeRef, activeTheme) }, [activeTheme])
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fn; setState runs after await
useEffect(() => { load() }, [load])
// Reload themes when window regains focus (live reload for external edits)
useEffect(() => {
const onFocus = () => { loadThemes() }
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [loadThemes])
window.addEventListener('focus', load)
return () => window.removeEventListener('focus', load)
}, [load])
return { activeThemeId, setActiveThemeId, reload: load }
}
/** Applies CSS custom properties to the document root from the active theme. */
function useThemeApplier(
activeThemeId: string | null,
cachedContent: string | undefined,
) {
const appliedVarsRef = useRef<Record<string, string>>({})
const [isDark, setIsDark] = useState(false)
const applyDom = useCallback((content: string) => {
const newVars = extractCssVars(content)
clearVarsFromDom(appliedVarsRef.current)
applyVarsToDom(newVars)
appliedVarsRef.current = newVars
return newVars
}, [])
const clearDom = useCallback(() => {
clearVarsFromDom(appliedVarsRef.current)
appliedVarsRef.current = {}
}, [])
// Apply theme when activeThemeId or cached content changes.
// Also serves as live-preview: re-applies when the user saves the theme note.
useEffect(() => {
if (!activeThemeId) {
clearDom()
setIsDark(false) // eslint-disable-line react-hooks/set-state-in-effect -- sync dark mode with cleared theme
return
}
if (cachedContent) {
const vars = applyDom(cachedContent)
setIsDark(isColorDark(vars['--background'] ?? ''))
return
}
tauriCall<string>('get_note_content', { path: activeThemeId })
.then(content => {
const vars = applyDom(content)
setIsDark(isColorDark(vars['--background'] ?? ''))
})
.catch(() => { clearDom(); setIsDark(false) })
}, [activeThemeId, cachedContent, applyDom, clearDom])
return { clearDom, isDark }
}
export function useThemeManager(
vaultPath: string | null,
entries: VaultEntry[],
allContent: Record<string, string>,
): ThemeManager {
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
const cachedThemeContent = activeThemeId ? allContent[activeThemeId] : undefined
const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent)
// Track IDs set by user actions (switchTheme/createTheme) so the stale-ID
// cleanup doesn't clear a newly-created theme that isn't in entries yet.
const userSetIdRef = useRef<string | null>(null)
const themes = useMemo(
() => entries
.filter(e => e.isA === 'Theme' && !e.trashed && !e.archived)
.map(e => entryToThemeFile(e, allContent[e.path])),
[entries, allContent],
)
const activeTheme = useMemo(
() => themes.find(t => t.id === activeThemeId) ?? null,
[themes, activeThemeId],
)
// If active theme ID doesn't match any known theme (e.g. stale ID from old
// JSON-based theme system), clear it so the app doesn't try to load a
// non-existent path. Skip IDs just set by switchTheme/createTheme — the
// entry may not have appeared in `entries` yet.
useEffect(() => {
if (!activeThemeId || themes.length === 0) return
if (activeThemeId === userSetIdRef.current) return
const isKnown = themes.some(t => t.id === activeThemeId)
if (!isKnown) {
clearTheme()
setActiveThemeId(null)
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
}
}, [activeThemeId, themes, clearTheme, vaultPath, setActiveThemeId])
// If active theme is trashed or archived: clear CSS vars and fall back to no theme
useEffect(() => {
if (!activeThemeId) return
const entry = entries.find(e => e.path === activeThemeId)
if (!entry || !isEntryRemoved(entry)) return
clearTheme()
setActiveThemeId(null)
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
}, [entries, activeThemeId, clearTheme, vaultPath, setActiveThemeId])
const switchTheme = useCallback(async (themeId: string) => {
if (!vaultPath) return
try {
await tauriCall<null>('set_active_theme', { vaultPath, themeId })
userSetIdRef.current = themeId
setActiveThemeId(themeId)
} catch (err) {
console.error('Failed to switch theme:', err)
}
}, [vaultPath])
} catch (err) { console.error('Failed to switch theme:', err) }
}, [vaultPath, setActiveThemeId])
const createTheme = useCallback(async (sourceId?: string) => {
const createTheme = useCallback(async (name?: string) => {
if (!vaultPath) return ''
try {
const newId = await tauriCall<string>('create_theme', {
vaultPath,
sourceId: sourceId ?? null,
})
await loadThemes()
await switchTheme(newId)
return newId
} catch (err) {
console.error('Failed to create theme:', err)
return ''
}
}, [vaultPath, loadThemes, switchTheme])
const path = await tauriCall<string>('create_vault_theme', { vaultPath, name: name ?? null })
await tauriCall<null>('set_active_theme', { vaultPath, themeId: path })
userSetIdRef.current = path
setActiveThemeId(path)
return path
} catch (err) { console.error('Failed to create theme:', err); return '' }
}, [vaultPath, setActiveThemeId])
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes: loadThemes }
const reloadThemes = useCallback(async () => { await reload() }, [reload])
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes }
}

View File

@@ -199,4 +199,73 @@ describe('useUpdater', () => {
expect(result.current.status).toEqual({ state: 'ready', version: '1.2.0' })
expect(mockDownload).toHaveBeenCalled()
})
describe('checkForUpdates (manual)', () => {
it('returns up-to-date when no update is available', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockResolvedValue(null)
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('up-to-date')
expect(result.current.status).toEqual({ state: 'idle' })
})
it('returns available and sets status when update exists', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockResolvedValue({
version: '3.0.0',
body: 'Major release',
downloadAndInstall: vi.fn(),
})
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('available')
expect(result.current.status).toEqual({
state: 'available',
version: '3.0.0',
notes: 'Major release',
})
})
it('returns error on network failure', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockRejectedValue(new Error('No internet'))
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('error')
expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates')
})
it('returns up-to-date when not in Tauri', async () => {
vi.mocked(isTauri).mockReturnValue(false)
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('up-to-date')
expect(mockCheck).not.toHaveBeenCalled()
})
})
})

View File

@@ -11,7 +11,10 @@ export type UpdateStatus =
| { state: 'ready'; version: string }
| { state: 'error' }
export type UpdateCheckResult = 'up-to-date' | 'available' | 'error'
export interface UpdateActions {
checkForUpdates: () => Promise<UpdateCheckResult>
startDownload: () => void
openReleaseNotes: () => void
dismiss: () => void
@@ -21,31 +24,32 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' })
const updateRef = useRef<unknown>(null)
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
if (!isTauri()) return 'up-to-date'
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
if (!update) return 'up-to-date'
updateRef.current = update
setStatus({
state: 'available',
version: update.version,
notes: update.body ?? undefined,
})
return 'available'
} catch {
console.warn('[updater] Failed to check for updates')
return 'error'
}
}, [])
useEffect(() => {
if (!isTauri()) return
const checkForUpdates = async () => {
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
if (!update) return // up to date
updateRef.current = update
setStatus({
state: 'available',
version: update.version,
notes: update.body ?? undefined,
})
} catch {
// Network error or 404 — fail silently
console.warn('[updater] Failed to check for updates')
}
}
// Delay so the app can render first
const timer = setTimeout(checkForUpdates, 3000)
const timer = setTimeout(() => { checkForUpdates() }, 3000)
return () => clearTimeout(timer)
}, [])
}, [checkForUpdates])
const startDownload = useCallback(async () => {
const update = updateRef.current as {
@@ -88,7 +92,7 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
setStatus({ state: 'idle' })
}, [])
return { status, actions: { startDownload, openReleaseNotes, dismiss } }
return { status, actions: { checkForUpdates, startDownload, openReleaseNotes, dismiss } }
}
/**

View File

@@ -10,7 +10,7 @@ const mockEntries: VaultEntry[] = [
status: 'Active', owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
},
]

View File

@@ -97,13 +97,14 @@ export function useVaultLoader(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [allContent, setAllContent] = useState<Record<string, string>>({})
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
const [modifiedFilesError, setModifiedFilesError] = useState<string | null>(null)
const tracker = useNewNoteTracker()
const pendingSave = usePendingSaveTracker()
const unsaved = useUnsavedTracker()
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear(); unsaved.clearAll()
setEntries([]); setAllContent({}); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
loadVaultData(vaultPath)
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
.catch((err) => console.warn('Vault scan failed:', err))
@@ -111,9 +112,12 @@ export function useVaultLoader(vaultPath: string) {
const loadModifiedFiles = useCallback(async () => {
try {
setModifiedFilesError(null)
setModifiedFiles(await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath }, {}))
} catch (err) {
const message = typeof err === 'string' ? err : 'Failed to load changes'
console.warn('Failed to load modified files:', err)
setModifiedFilesError(message)
setModifiedFiles([])
}
}, [vaultPath])
@@ -174,7 +178,7 @@ export function useVaultLoader(vaultPath: string) {
)
return {
entries, allContent, modifiedFiles,
entries, allContent, modifiedFiles, modifiedFilesError,
addEntry, updateEntry, removeEntry, replaceEntry, updateContent,
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
getNoteStatus, commitAndPush, reloadVault,

View File

@@ -0,0 +1,374 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useVaultSwitcher, DEFAULT_VAULTS } from './useVaultSwitcher'
import type { PersistedVaultList } from './useVaultSwitcher'
let mockVaultListStore: PersistedVaultList = { vaults: [], active_vault: null, hidden_defaults: [] }
const mockInvokeFn = vi.fn((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'check_vault_exists') return Promise.resolve(true)
return Promise.resolve(null)
})
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
describe('useVaultSwitcher', () => {
const onSwitch = vi.fn()
const onToast = vi.fn()
beforeEach(() => {
vi.resetAllMocks()
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
// Re-set default implementation after resetAllMocks
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'check_vault_exists') return Promise.resolve(true)
return Promise.resolve(null)
})
})
it('starts with default vaults', () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
expect(result.current.allVaults).toEqual(DEFAULT_VAULTS)
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
})
it('loads persisted vaults on mount', async () => {
mockVaultListStore = {
vaults: [{ label: 'My Vault', path: '/Users/luca/Laputa' }],
active_vault: '/Users/luca/Laputa',
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => {
expect(result.current.loaded).toBe(true)
})
expect(result.current.allVaults).toHaveLength(2) // default + persisted
expect(result.current.allVaults[1].label).toBe('My Vault')
expect(result.current.allVaults[1].path).toBe('/Users/luca/Laputa')
expect(result.current.allVaults[1].available).toBe(true)
expect(result.current.vaultPath).toBe('/Users/luca/Laputa')
expect(mockInvokeFn).toHaveBeenCalledWith('load_vault_list', {})
})
it('marks unavailable vaults when check_vault_exists returns false', async () => {
mockVaultListStore = {
vaults: [{ label: 'External', path: '/Volumes/USB/vault' }],
active_vault: null,
}
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'check_vault_exists') return Promise.resolve(false)
return Promise.resolve(null)
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => {
expect(result.current.loaded).toBe(true)
})
expect(result.current.allVaults[1].available).toBe(false)
expect(result.current.allVaults[1].label).toBe('External')
})
it('persists vault list when adding a vault via handleVaultCloned', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
act(() => {
result.current.handleVaultCloned('/cloned/vault', 'Cloned')
})
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
list: expect.objectContaining({
vaults: expect.arrayContaining([
expect.objectContaining({ label: 'Cloned', path: '/cloned/vault' }),
]),
}),
}))
})
})
it('persists active vault when switching', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: null,
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
act(() => {
result.current.switchVault('/work/vault')
})
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
list: expect.objectContaining({
active_vault: '/work/vault',
}),
}))
})
expect(onSwitch).toHaveBeenCalled()
})
it('handles load error gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'load_vault_list') return Promise.reject(new Error('disk error'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
// Should fall back to defaults
expect(result.current.allVaults).toEqual(DEFAULT_VAULTS)
warnSpy.mockRestore()
})
it('does not duplicate vaults with same path', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
act(() => {
result.current.handleVaultCloned('/some/vault', 'First')
})
act(() => {
result.current.handleVaultCloned('/some/vault', 'Duplicate')
})
const extras = result.current.allVaults.filter(v => v.path === '/some/vault')
expect(extras).toHaveLength(1)
})
it('opens local folder and persists', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/MyVault')
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
await act(async () => {
await result.current.handleOpenLocalFolder()
})
expect(result.current.allVaults.some(v => v.path === '/Users/luca/MyVault')).toBe(true)
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
})
describe('removeVault', () => {
it('removes an extra vault from the list', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: null,
hidden_defaults: [],
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.allVaults).toHaveLength(2) // default + Work
act(() => {
result.current.removeVault('/work/vault')
})
expect(result.current.allVaults.some(v => v.path === '/work/vault')).toBe(false)
expect(onToast).toHaveBeenCalledWith('Vault "Work" removed from list')
})
it('hides a default vault instead of deleting it', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
const defaultPath = DEFAULT_VAULTS[0].path
expect(result.current.allVaults.some(v => v.path === defaultPath)).toBe(true)
act(() => {
result.current.removeVault(defaultPath)
})
expect(result.current.allVaults.some(v => v.path === defaultPath)).toBe(false)
expect(result.current.isGettingStartedHidden).toBe(true)
})
it('switches to another vault when removing the active vault', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [],
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.vaultPath).toBe('/work/vault')
act(() => {
result.current.removeVault('/work/vault')
})
// Should switch to the default vault
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
})
it('shows toast when vault is removed', async () => {
mockVaultListStore = {
vaults: [{ label: 'Docs', path: '/docs/vault' }],
active_vault: null,
hidden_defaults: [],
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
act(() => {
result.current.removeVault('/docs/vault')
})
expect(onToast).toHaveBeenCalledWith('Vault "Docs" removed from list')
})
it('persists hidden_defaults when removing a default vault', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
// Add another vault first so we're not removing the last one
act(() => {
result.current.handleVaultCloned('/other/vault', 'Other')
})
act(() => {
result.current.removeVault(DEFAULT_VAULTS[0].path)
})
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
list: expect.objectContaining({
hidden_defaults: [DEFAULT_VAULTS[0].path],
}),
}))
})
})
})
describe('restoreGettingStarted', () => {
it('un-hides the Getting Started vault', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [DEFAULT_VAULTS[0].path],
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.isGettingStartedHidden).toBe(true)
await act(async () => {
await result.current.restoreGettingStarted()
})
expect(result.current.isGettingStartedHidden).toBe(false)
expect(result.current.allVaults.some(v => v.path === DEFAULT_VAULTS[0].path)).toBe(true)
})
it('switches to the Getting Started vault after restoring', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [DEFAULT_VAULTS[0].path],
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
await act(async () => {
await result.current.restoreGettingStarted()
})
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
expect(onToast).toHaveBeenCalledWith('Getting Started vault restored')
})
it('attempts to create vault on disk if it does not exist', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [DEFAULT_VAULTS[0].path],
}
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'check_vault_exists') return Promise.resolve(false)
if (cmd === 'create_getting_started_vault') return Promise.resolve(DEFAULT_VAULTS[0].path)
return Promise.resolve(null)
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
await act(async () => {
await result.current.restoreGettingStarted()
})
expect(mockInvokeFn).toHaveBeenCalledWith('check_vault_exists', { path: DEFAULT_VAULTS[0].path })
expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', { targetPath: DEFAULT_VAULTS[0].path })
})
})
describe('isGettingStartedHidden', () => {
it('is false by default', async () => {
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.isGettingStartedHidden).toBe(false)
})
it('is true when Getting Started path is in hidden_defaults', async () => {
mockVaultListStore = {
vaults: [],
active_vault: null,
hidden_defaults: [DEFAULT_VAULTS[0].path],
}
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
expect(result.current.isGettingStartedHidden).toBe(true)
})
})
})

View File

@@ -1,36 +1,91 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isTauri } from '../mock-tauri'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
import type { VaultOption } from '../components/StatusBar'
export const DEFAULT_VAULTS: VaultOption[] = isTauri()
? [
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
{ label: 'Laputa', path: '/Users/luca/Laputa' },
]
: [
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
]
export type { PersistedVaultList } from '../utils/vaultListStore'
export const GETTING_STARTED_LABEL = 'Getting Started'
export const DEFAULT_VAULTS: VaultOption[] = [
{ label: GETTING_STARTED_LABEL, path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
]
interface UseVaultSwitcherOptions {
onSwitch: () => void
onToast: (msg: string) => void
}
/** Manages vault path, extra vaults, switching, cloning, and local folder opening. */
function labelFromPath(path: string): string {
return path.split('/').pop() || 'Local Vault'
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
/** Manages vault path, extra vaults, switching, cloning, and local folder opening.
* Vault list and active vault are persisted via Tauri backend to survive app updates. */
export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) {
const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path)
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults])
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
const [loaded, setLoaded] = useState(false)
const visibleDefaults = useMemo(
() => DEFAULT_VAULTS.filter(v => !hiddenDefaults.includes(v.path)),
[hiddenDefaults],
)
const allVaults = useMemo(
() => [...visibleDefaults, ...extraVaults],
[visibleDefaults, extraVaults],
)
const isGettingStartedHidden = useMemo(
() => hiddenDefaults.includes(DEFAULT_VAULTS[0].path),
[hiddenDefaults],
)
// Refs ensure stable callbacks that always invoke the latest closures,
// breaking the circular dependency between useVaultSwitcher and downstream hooks.
const onSwitchRef = useRef(onSwitch)
const onToastRef = useRef(onToast)
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
const hasLoadedRef = useRef(false)
useEffect(() => {
let cancelled = false
loadVaultList()
.then(({ vaults, activeVault, hiddenDefaults: hidden }) => {
if (cancelled) return
setExtraVaults(vaults)
setHiddenDefaults(hidden)
if (activeVault) {
setVaultPath(activeVault)
onSwitchRef.current()
}
})
.catch(err => console.warn('Failed to load vault list:', err))
.finally(() => {
hasLoadedRef.current = true
setLoaded(true)
})
return () => { cancelled = true }
}, [])
useEffect(() => {
if (!hasLoadedRef.current) return
saveVaultList(extraVaults, vaultPath, hiddenDefaults).catch(err =>
console.warn('Failed to persist vault list:', err),
)
}, [extraVaults, vaultPath, hiddenDefaults])
const addVault = useCallback((path: string, label: string) => {
setExtraVaults(prev => prev.some(v => v.path === path) ? prev : [...prev, { label, path }])
setExtraVaults(prev => {
const exists = prev.some(v => v.path === path)
return exists ? prev : [...prev, { label, path, available: true }]
})
}, [])
const switchVault = useCallback((path: string) => {
@@ -38,25 +93,69 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
onSwitchRef.current()
}, [])
const handleVaultCloned = useCallback((path: string, label: string) => {
const addAndSwitch = useCallback((path: string, label: string) => {
addVault(path, label)
switchVault(path)
onToastRef.current(`Vault "${label}" cloned and opened`)
}, [addVault, switchVault])
const handleVaultCloned = useCallback((path: string, label: string) => {
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" cloned and opened`)
}, [addAndSwitch])
const handleOpenLocalFolder = useCallback(async () => {
try {
const path = await pickFolder('Open vault folder')
if (!path) return
const label = path.split('/').pop() || 'Local Vault'
addVault(path, label)
switchVault(path)
onToastRef.current(`Vault "${label}" opened`)
} catch (err) {
console.error('Failed to open local folder:', err)
onToastRef.current(`Failed to open folder: ${err}`)
}
}, [addVault, switchVault])
const path = await pickFolder('Open vault folder')
if (!path) return
const label = labelFromPath(path)
addAndSwitch(path, label)
onToastRef.current(`Vault "${label}" opened`)
}, [addAndSwitch])
return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder }
const removeVault = useCallback((path: string) => {
const isDefault = DEFAULT_VAULTS.some(v => v.path === path)
if (isDefault) {
setHiddenDefaults(prev => prev.includes(path) ? prev : [...prev, path])
} else {
setExtraVaults(prev => prev.filter(v => v.path !== path))
}
// If removing the active vault, switch to the first remaining vault
setVaultPath(currentPath => {
if (currentPath !== path) return currentPath
const remaining = [
...DEFAULT_VAULTS.filter(v => v.path !== path && !(isDefault ? [] : hiddenDefaults).includes(v.path)),
...extraVaults.filter(v => v.path !== path),
]
if (remaining.length > 0) {
onSwitchRef.current()
return remaining[0].path
}
return currentPath
})
const vault = [...DEFAULT_VAULTS, ...extraVaults].find(v => v.path === path)
onToastRef.current(`Vault "${vault?.label ?? labelFromPath(path)}" removed from list`)
}, [extraVaults, hiddenDefaults])
const restoreGettingStarted = useCallback(async () => {
const defaultPath = DEFAULT_VAULTS[0].path
// Un-hide the Getting Started vault
setHiddenDefaults(prev => prev.filter(p => p !== defaultPath))
// Try to create the vault if it doesn't exist on disk
try {
const exists = await tauriCall<boolean>('check_vault_exists', { path: defaultPath })
if (!exists) {
await tauriCall<string>('create_getting_started_vault', { targetPath: defaultPath })
}
} catch {
// In mock/test mode, creation may fail — that's fine
}
switchVault(defaultPath)
onToastRef.current('Getting Started vault restored')
}, [switchVault])
return {
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,
removeVault, restoreGettingStarted, isGettingStartedHidden,
}
}

View File

@@ -732,5 +732,68 @@ rating: 5
# Designing Data-Intensive Applications
Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing.
`,
'/Users/luca/Laputa/theme/default.md': `---
Is A: Theme
title: Default
primary: "#155DFF"
background: "#FFFFFF"
foreground: "#37352F"
sidebar: "#F7F6F3"
border: "#E9E9E7"
muted: "#F0F0EF"
muted-foreground: "#9B9A97"
accent: "#F0F7FF"
accent-foreground: "#0A3B8F"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14
line-height-base: 1.6
---
# Default
Light theme with warm, paper-like tones.
`,
'/Users/luca/Laputa/theme/dark.md': `---
Is A: Theme
title: Dark
primary: "#155DFF"
background: "#0f0f1a"
foreground: "#e0e0e0"
sidebar: "#1a1a2e"
border: "#2a2a4a"
muted: "#1e1e3a"
muted-foreground: "#6b6b8a"
accent: "#1a2a4a"
accent-foreground: "#8ab4ff"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14
line-height-base: 1.6
---
# Dark
Dark variant with deep navy tones.
`,
'/Users/luca/Laputa/theme/minimal.md': `---
Is A: Theme
title: Minimal
primary: "#000000"
background: "#FAFAFA"
foreground: "#111111"
sidebar: "#F5F5F5"
border: "#E0E0E0"
muted: "#F5F5F5"
muted-foreground: "#888888"
accent: "#F0F0F0"
accent-foreground: "#000000"
font-family: "'SF Mono', 'Menlo', monospace"
font-size-base: 13
line-height-base: 1.5
---
# Minimal
High contrast, minimal chrome.
`,
}

View File

@@ -36,8 +36,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
properties: { Priority: 'High', 'Due date': '2026-06-15' },
},
{
path: '/Users/luca/Laputa/responsibility/grow-newsletter.md',
@@ -72,8 +73,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly' },
},
{
path: '/Users/luca/Laputa/responsibility/manage-sponsorships.md',
@@ -102,8 +104,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['person/matteo-cellini'],
properties: {},
},
{
path: '/Users/luca/Laputa/procedure/write-weekly-essays.md',
@@ -132,8 +135,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
{
path: '/Users/luca/Laputa/procedure/run-sponsorships.md',
@@ -162,8 +166,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['responsibility/manage-sponsorships'],
properties: {},
},
{
path: '/Users/luca/Laputa/experiment/stock-screener.md',
@@ -193,8 +198,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
properties: { Priority: 'Low', 'Due date': '2026-03-01' },
},
{
path: '/Users/luca/Laputa/note/facebook-ads-strategy.md',
@@ -224,8 +230,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
properties: { Priority: 'Medium', Rating: 4 },
},
{
path: '/Users/luca/Laputa/note/budget-allocation.md',
@@ -254,8 +261,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['project/26q1-laputa-app'],
properties: {},
},
{
path: '/Users/luca/Laputa/person/matteo-cellini.md',
@@ -283,8 +291,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: { Company: 'Acme Corp', Role: 'Engineering Lead' },
},
{
path: '/Users/luca/Laputa/person/maria-bianchi.md',
@@ -312,8 +321,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: { Company: 'TechStart', Role: 'Product Manager' },
},
{
path: '/Users/luca/Laputa/person/marco-verdi.md',
@@ -341,8 +351,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/person/elena-russo.md',
@@ -370,8 +381,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/event/2026-02-14-laputa-app-kickoff.md',
@@ -400,8 +412,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
properties: {},
},
{
path: '/Users/luca/Laputa/topic/software-development.md',
@@ -430,8 +443,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/topic/trading.md',
@@ -460,8 +474,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/essay/on-writing-well.md',
@@ -490,8 +505,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
{
path: '/Users/luca/Laputa/essay/engineering-leadership-101.md',
@@ -521,8 +537,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
properties: {},
},
{
path: '/Users/luca/Laputa/essay/ai-agents-primer.md',
@@ -551,8 +568,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
// --- Type documents ---
{
@@ -579,8 +597,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 0,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/responsibility.md',
@@ -606,8 +625,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 1,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/procedure.md',
@@ -633,8 +653,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 2,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/experiment.md',
@@ -660,8 +681,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 3,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/person.md',
@@ -687,8 +709,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 4,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/event.md',
@@ -714,8 +737,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 5,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/topic.md',
@@ -741,8 +765,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 6,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/essay.md',
@@ -768,8 +793,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 7,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/note.md',
@@ -795,8 +821,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 8,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
// --- Custom type documents ---
{
@@ -823,8 +850,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'orange',
order: 9,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/type/book.md',
@@ -850,8 +878,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'green',
order: 10,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
// --- Instances of custom types ---
{
@@ -880,8 +909,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 },
},
{
path: '/Users/luca/Laputa/book/designing-data-intensive-applications.md',
@@ -909,8 +939,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 },
},
// --- Trashed entries ---
{
@@ -940,8 +971,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/note/deprecated-api-notes.md',
@@ -969,8 +1001,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/experiment/failed-seo-experiment.md',
@@ -999,8 +1032,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
// --- Archived entries ---
{
@@ -1021,8 +1055,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
modifiedAt: now - 86400 * 120,
createdAt: now - 86400 * 200,
fileSize: 680,
@@ -1051,8 +1086,9 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
modifiedAt: now - 86400 * 90,
createdAt: now - 86400 * 150,
fileSize: 520,
@@ -1115,11 +1151,100 @@ function generateBulkEntries(count: number): VaultEntry[] {
order: null,
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
sidebarLabel: null,
template: null,
template: null, sort: null,
properties: {},
})
}
return entries
}
// Theme entries — seeded vault themes
MOCK_ENTRIES.push(
{
path: '/Users/luca/Laputa/theme/default.md',
filename: 'default.md',
title: 'Default',
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 30,
fileSize: 512,
snippet: 'Light theme with warm, paper-like tones.',
wordCount: 10,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/theme/dark.md',
filename: 'dark.md',
title: 'Dark',
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 30,
fileSize: 512,
snippet: 'Dark variant with deep navy tones.',
wordCount: 10,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/theme/minimal.md',
filename: 'minimal.md',
title: 'Minimal',
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 30,
fileSize: 512,
snippet: 'High contrast, minimal chrome.',
wordCount: 10,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
)
// Append 9000 generated entries for realistic large-vault testing
MOCK_ENTRIES.push(...generateBulkEntries(9000))

View File

@@ -82,8 +82,15 @@ let mockSettings: Settings = {
auto_pull_interval_minutes: 5,
}
let mockLastVaultPath: string | null = null
let mockVaultSettings: VaultSettings = { theme: null }
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
vaults: [],
active_vault: null,
}
const mockThemes: ThemeFile[] = [
{
id: 'default', name: 'Default', description: 'Light theme with warm, paper-like tones',
@@ -197,6 +204,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
}
return null
},
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
rename_note: handleRenameNote,
github_list_repos: () => [
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
@@ -212,6 +221,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
}),
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
purge_trash: () => [],
delete_note: (args: { path: string }) => args.path,
migrate_is_a_to_type: () => 0,
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,
@@ -245,13 +255,18 @@ export const mockHandlers: Record<string, (args: any) => any> = {
}))
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
},
get_default_vault_path: () => '/Users/mock/Documents/Laputa',
get_last_vault_path: () => mockLastVaultPath,
set_last_vault_path: (args: { path: string }) => { mockLastVaultPath = args.path; return null },
get_default_vault_path: () => '/Users/mock/Documents/Getting Started',
check_vault_exists: (args: { path: string }) => {
// In mock mode, the demo-vault-v2 path always "exists"
return args.path.includes('demo-vault-v2')
},
create_getting_started_vault: () => '/Users/mock/Documents/Laputa',
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0 }),
start_indexing: () => null,
trigger_incremental_index: () => null,
list_themes: (): ThemeFile[] => [...mockThemes],
get_theme: (args: { themeId: string }): ThemeFile => {
const t = mockThemes.find(t => t.id === args.themeId)
@@ -268,6 +283,32 @@ export const mockHandlers: Record<string, (args: any) => any> = {
mockThemes.push({ ...source, id: newId, name: 'Untitled Theme' })
return newId
},
create_vault_theme: (args: { vaultPath: string; name?: string | null }): string => {
const displayName = args.name ?? 'Untitled Theme'
const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme'
const path = `${args.vaultPath}/theme/${slug}.md`
MOCK_CONTENT[path] = `---
Is A: Theme
title: ${displayName}
primary: "#155DFF"
background: "#FFFFFF"
foreground: "#37352F"
sidebar: "#F7F6F3"
border: "#E9E9E7"
muted: "#F0F0EF"
muted-foreground: "#9B9A97"
accent: "#F0F7FF"
accent-foreground: "#0A3B8F"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14
line-height-base: 1.6
---
# ${displayName}
`
syncWindowContent()
return path
},
}
export function addMockEntry(_entry: VaultEntry, content: string): void {

View File

@@ -1,7 +1,7 @@
{
"editor": {
"fontFamily": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"fontSize": 16,
"fontSize": 15,
"lineHeight": 1.5,
"maxWidth": 720,
"paddingHorizontal": 40,
@@ -25,7 +25,7 @@
"marginTop": 28,
"marginBottom": 10,
"color": "var(--text-heading)",
"letterSpacing": 0
"letterSpacing": -0.5
},
"h3": {
"fontSize": 20,
@@ -34,7 +34,7 @@
"marginTop": 24,
"marginBottom": 8,
"color": "var(--text-heading)",
"letterSpacing": 0
"letterSpacing": -0.5
},
"h4": {
"fontSize": 20,

View File

@@ -29,8 +29,12 @@ export interface VaultEntry {
sidebarLabel: string | null
/** Markdown template for Type entries. Pre-fills new notes created with this type. */
template: string | null
/** Default sort preference for the note list of this Type. Format: "option:direction". */
sort: string | null
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
outgoingLinks: string[]
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
properties: Record<string, string | number | boolean | null>
}
export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' | 'unsaved'
@@ -120,9 +124,12 @@ export interface SearchResponse {
export type SearchMode = 'keyword' | 'semantic' | 'hybrid'
export interface ThemeFile {
/** For vault-based themes: absolute note path. For legacy JSON themes: filename stem. */
id: string
name: string
description: string
/** Absolute path to the vault note (vault-based themes only). */
path?: string
colors: Record<string, string>
typography: Record<string, string>
spacing: Record<string, string>

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { formatSubtitle, formatSearchSubtitle, relativeDate } from './noteListHelpers'
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig } from './noteListHelpers'
import type { VaultEntry } from '../types'
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
@@ -10,7 +10,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0,
snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, template: null, outgoingLinks: [],
icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
...overrides,
}
}
@@ -154,3 +154,334 @@ describe('relativeDate', () => {
expect(relativeDate(1700000000)).toMatch(/Nov 14/)
})
})
// --- buildRelationshipGroups tests ---
function makeVault(overrides: Partial<VaultEntry>[]): VaultEntry[] {
return overrides.map((o, i) => makeEntry({
path: `/Laputa/note/entry-${i}.md`,
filename: `entry-${i}.md`,
title: `Entry ${i}`,
modifiedAt: 1700000000 - i * 100,
...o,
}))
}
describe('buildRelationshipGroups', () => {
it('shows direct relationship properties from entity.relationships', () => {
const building = makeEntry({ path: '/Laputa/responsibility/building.md', filename: 'building.md', title: 'Building' })
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { 'Belongs to': ['[[responsibility/building]]'] },
})
const groups = buildRelationshipGroups(entity, [entity, building], {})
const labels = groups.map((g) => g.label)
expect(labels).toContain('Belongs to')
expect(groups.find((g) => g.label === 'Belongs to')!.entries[0].title).toBe('Building')
})
it('shows all direct relationships even when entries also appear as Children', () => {
// The entity has "Notes" pointing at note1 and note2.
// Those notes also have belongsTo pointing back at the entity.
// Previously, Children consumed them via the seen set, suppressing "Notes".
const note1 = makeEntry({ path: '/Laputa/note/note1.md', filename: 'note1.md', title: 'Note 1', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
const note2 = makeEntry({ path: '/Laputa/note/note2.md', filename: 'note2.md', title: 'Note 2', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { Notes: ['[[note/note1]]', '[[note/note2]]'] },
})
const groups = buildRelationshipGroups(entity, [entity, note1, note2], {})
const labels = groups.map((g) => g.label)
expect(labels).toContain('Notes')
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
})
it('shows all 5+ direct relationship properties', () => {
const entries = makeVault([
{ path: '/Laputa/area/eng.md', filename: 'eng.md', title: 'Engineering' },
{ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' },
{ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' },
{ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' },
{ path: '/Laputa/topic/rust.md', filename: 'rust.md', title: 'Rust' },
{ path: '/Laputa/project/sibling.md', filename: 'sibling.md', title: 'Sibling' },
])
const entity = makeEntry({
path: '/Laputa/project/big.md', filename: 'big.md', title: 'Big Project',
relationships: {
'Belongs to': ['[[area/eng]]'],
Notes: ['[[note/n1]]', '[[note/n2]]'],
Owner: ['[[person/alice]]'],
'Related to': ['[[project/sibling]]'],
Topics: ['[[topic/rust]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, ...entries], {})
const labels = groups.map((g) => g.label)
expect(labels).toContain('Belongs to')
expect(labels).toContain('Notes')
expect(labels).toContain('Owner')
expect(labels).toContain('Related to')
expect(labels).toContain('Topics')
})
it('shows Children group for reverse belongsTo entries not covered by direct rels', () => {
const child = makeEntry({ path: '/Laputa/note/child.md', filename: 'child.md', title: 'Child', belongsTo: ['[[project/alpha]]'], modifiedAt: 1700000000 })
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, child], {})
const labels = groups.map((g) => g.label)
expect(labels).toContain('Children')
expect(groups.find((g) => g.label === 'Children')!.entries[0].title).toBe('Child')
})
it('excludes Type key from relationship groups', () => {
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: { Type: ['[[type/project]]'] },
})
const groups = buildRelationshipGroups(entity, [entity], {})
const labels = groups.map((g) => g.label)
expect(labels).not.toContain('Type')
})
it('returns empty groups for entity with no relationships', () => {
const entity = makeEntry({ path: '/Laputa/note/solo.md', filename: 'solo.md', title: 'Solo', relationships: {} })
const groups = buildRelationshipGroups(entity, [entity], {})
expect(groups).toHaveLength(0)
})
it('shows single-item and multi-item relationship properties', () => {
const alice = makeEntry({ path: '/Laputa/person/alice.md', filename: 'alice.md', title: 'Alice' })
const n1 = makeEntry({ path: '/Laputa/note/n1.md', filename: 'n1.md', title: 'Note 1' })
const n2 = makeEntry({ path: '/Laputa/note/n2.md', filename: 'n2.md', title: 'Note 2' })
const entity = makeEntry({
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
relationships: {
Owner: ['[[person/alice]]'],
Notes: ['[[note/n1]]', '[[note/n2]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, alice, n1, n2], {})
expect(groups.find((g) => g.label === 'Owner')!.entries).toHaveLength(1)
expect(groups.find((g) => g.label === 'Notes')!.entries).toHaveLength(2)
})
it('shows Instances group for Type entities', () => {
const instance1 = makeEntry({ path: '/Laputa/project/a.md', filename: 'a.md', title: 'Project A', isA: 'Project', modifiedAt: 1700000000 })
const instance2 = makeEntry({ path: '/Laputa/project/b.md', filename: 'b.md', title: 'Project B', isA: 'Project', modifiedAt: 1700000000 })
const typeEntity = makeEntry({
path: '/Laputa/type/project.md', filename: 'project.md', title: 'Project',
isA: 'Type', relationships: {},
})
const groups = buildRelationshipGroups(typeEntity, [typeEntity, instance1, instance2], {})
const labels = groups.map((g) => g.label)
expect(labels).toContain('Instances')
expect(groups.find((g) => g.label === 'Instances')!.entries).toHaveLength(2)
})
it('direct relationships are sorted alphabetically', () => {
const a = makeEntry({ path: '/Laputa/note/a.md', filename: 'a.md', title: 'A' })
const b = makeEntry({ path: '/Laputa/note/b.md', filename: 'b.md', title: 'B' })
const c = makeEntry({ path: '/Laputa/note/c.md', filename: 'c.md', title: 'C' })
const entity = makeEntry({
path: '/Laputa/project/x.md', filename: 'x.md', title: 'X',
relationships: {
Zebra: ['[[note/c]]'],
Alpha: ['[[note/a]]'],
Middle: ['[[note/b]]'],
},
})
const groups = buildRelationshipGroups(entity, [entity, a, b, c], {})
const directLabels = groups.map((g) => g.label)
expect(directLabels.indexOf('Alpha')).toBeLessThan(directLabels.indexOf('Middle'))
expect(directLabels.indexOf('Middle')).toBeLessThan(directLabels.indexOf('Zebra'))
})
it('Referenced By shows entries whose relatedTo matches the entity', () => {
const referer = makeEntry({
path: '/Laputa/project/ref.md', filename: 'ref.md', title: 'Referer',
relatedTo: ['[[project/alpha]]'], modifiedAt: 1700000000,
})
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, referer], {})
expect(groups.find((g) => g.label === 'Referenced By')!.entries[0].title).toBe('Referer')
})
it('Backlinks shows entries that mention the entity via wikilinks in content', () => {
const linker = makeEntry({
path: '/Laputa/note/linker.md', filename: 'linker.md', title: 'Linker', modifiedAt: 1700000000,
})
const entity = makeEntry({
path: '/Laputa/project/alpha.md', filename: 'alpha.md', title: 'Alpha',
relationships: {},
})
const allContent = { '/Laputa/note/linker.md': 'See [[Alpha]] for details.' }
const groups = buildRelationshipGroups(entity, [entity, linker], allContent)
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
})
})
describe('getSortComparator — custom properties', () => {
it('sorts by string property alphabetically', () => {
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
const b = makeEntry({ title: 'B', properties: { Priority: 'Low' } })
const c = makeEntry({ title: 'C', properties: { Priority: 'Medium' } })
const sorted = [a, b, c].sort(getSortComparator('property:Priority'))
expect(sorted.map((e) => e.title)).toEqual(['A', 'B', 'C'])
})
it('sorts by numeric property', () => {
const a = makeEntry({ title: 'A', properties: { Rating: 3 } })
const b = makeEntry({ title: 'B', properties: { Rating: 5 } })
const c = makeEntry({ title: 'C', properties: { Rating: 1 } })
const sorted = [a, b, c].sort(getSortComparator('property:Rating'))
expect(sorted.map((e) => e.title)).toEqual(['C', 'A', 'B'])
})
it('sorts by date property chronologically', () => {
const a = makeEntry({ title: 'A', properties: { 'Due date': '2026-06-15' } })
const b = makeEntry({ title: 'B', properties: { 'Due date': '2026-01-01' } })
const c = makeEntry({ title: 'C', properties: { 'Due date': '2026-03-10' } })
const sorted = [a, b, c].sort(getSortComparator('property:Due date'))
expect(sorted.map((e) => e.title)).toEqual(['B', 'C', 'A'])
})
it('pushes null values to end regardless of direction', () => {
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
const b = makeEntry({ title: 'B', properties: {} })
const c = makeEntry({ title: 'C', properties: { Priority: 'Low' } })
const ascSorted = [a, b, c].sort(getSortComparator('property:Priority', 'asc'))
expect(ascSorted.map((e) => e.title)).toEqual(['A', 'C', 'B'])
const descSorted = [a, b, c].sort(getSortComparator('property:Priority', 'desc'))
expect(descSorted.map((e) => e.title)).toEqual(['C', 'A', 'B'])
})
it('sorts descending when direction is desc', () => {
const a = makeEntry({ title: 'A', properties: { Rating: 3 } })
const b = makeEntry({ title: 'B', properties: { Rating: 5 } })
const c = makeEntry({ title: 'C', properties: { Rating: 1 } })
const sorted = [a, b, c].sort(getSortComparator('property:Rating', 'desc'))
expect(sorted.map((e) => e.title)).toEqual(['B', 'A', 'C'])
})
it('handles entries with no properties field gracefully', () => {
const a = makeEntry({ title: 'A', properties: { Priority: 'High' } })
const b = makeEntry({ title: 'B', properties: {} })
const sorted = [a, b].sort(getSortComparator('property:Priority'))
expect(sorted.map((e) => e.title)).toEqual(['A', 'B'])
})
it('handles boolean property sorting', () => {
const a = makeEntry({ title: 'A', properties: { Reviewed: true } })
const b = makeEntry({ title: 'B', properties: { Reviewed: false } })
const sorted = [a, b].sort(getSortComparator('property:Reviewed'))
expect(sorted.map((e) => e.title)).toEqual(['B', 'A'])
})
})
describe('extractSortableProperties', () => {
it('returns union of all property keys across entries', () => {
const entries = [
makeEntry({ properties: { Priority: 'High', Rating: 5 } }),
makeEntry({ properties: { Priority: 'Low', Company: 'Acme' } }),
]
expect(extractSortableProperties(entries)).toEqual(['Company', 'Priority', 'Rating'])
})
it('returns empty array for entries without properties', () => {
const entries = [makeEntry(), makeEntry()]
expect(extractSortableProperties(entries)).toEqual([])
})
it('returns empty array for empty entry list', () => {
expect(extractSortableProperties([])).toEqual([])
})
it('deduplicates property keys', () => {
const entries = [
makeEntry({ properties: { Priority: 'High' } }),
makeEntry({ properties: { Priority: 'Low' } }),
]
expect(extractSortableProperties(entries)).toEqual(['Priority'])
})
})
describe('getSortOptionLabel', () => {
it('returns label for built-in options', () => {
expect(getSortOptionLabel('modified')).toBe('Modified')
expect(getSortOptionLabel('title')).toBe('Title')
})
it('returns property key for custom properties', () => {
expect(getSortOptionLabel('property:Priority')).toBe('Priority')
expect(getSortOptionLabel('property:Due date')).toBe('Due date')
})
})
describe('getDefaultDirection', () => {
it('returns desc for time-based sorts', () => {
expect(getDefaultDirection('modified')).toBe('desc')
expect(getDefaultDirection('created')).toBe('desc')
})
it('returns asc for other sorts', () => {
expect(getDefaultDirection('title')).toBe('asc')
expect(getDefaultDirection('status')).toBe('asc')
expect(getDefaultDirection('property:Priority')).toBe('asc')
})
})
describe('serializeSortConfig', () => {
it('serializes a built-in sort config', () => {
expect(serializeSortConfig({ option: 'modified', direction: 'desc' })).toBe('modified:desc')
expect(serializeSortConfig({ option: 'title', direction: 'asc' })).toBe('title:asc')
})
it('serializes a custom property sort config', () => {
expect(serializeSortConfig({ option: 'property:Priority', direction: 'asc' })).toBe('property:Priority:asc')
})
})
describe('parseSortConfig', () => {
it('parses a built-in sort config', () => {
expect(parseSortConfig('modified:desc')).toEqual({ option: 'modified', direction: 'desc' })
expect(parseSortConfig('title:asc')).toEqual({ option: 'title', direction: 'asc' })
})
it('parses a custom property sort config with colon in option', () => {
expect(parseSortConfig('property:Priority:asc')).toEqual({ option: 'property:Priority', direction: 'asc' })
})
it('returns null for null/undefined input', () => {
expect(parseSortConfig(null)).toBeNull()
expect(parseSortConfig(undefined)).toBeNull()
})
it('returns null for empty string', () => {
expect(parseSortConfig('')).toBeNull()
})
it('returns null for invalid direction', () => {
expect(parseSortConfig('modified:up')).toBeNull()
})
it('returns null for string without colon', () => {
expect(parseSortConfig('modified')).toBeNull()
})
it('roundtrips correctly', () => {
const configs = [
{ option: 'modified' as const, direction: 'desc' as const },
{ option: 'title' as const, direction: 'asc' as const },
{ option: 'property:Due date' as const, direction: 'desc' as const },
]
for (const config of configs) {
expect(parseSortConfig(serializeSortConfig(config))).toEqual(config)
}
})
})

View File

@@ -85,7 +85,7 @@ export function sortByModified(a: VaultEntry, b: VaultEntry): number {
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
export type SortOption = 'modified' | 'created' | 'title' | 'status'
export type SortOption = 'modified' | 'created' | 'title' | 'status' | `property:${string}`
export type SortDirection = 'asc' | 'desc'
export interface SortConfig {
@@ -93,11 +93,11 @@ export interface SortConfig {
direction: SortDirection
}
export const DEFAULT_DIRECTIONS: Record<SortOption, SortDirection> = {
modified: 'desc',
created: 'desc',
title: 'asc',
status: 'asc',
export const DEFAULT_SORT_OPTIONS: SortOption[] = ['modified', 'created', 'title', 'status']
export function getDefaultDirection(option: SortOption): SortDirection {
if (option === 'modified' || option === 'created') return 'desc'
return 'asc'
}
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
@@ -107,33 +107,99 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: 'status', label: 'Status' },
]
export function getSortOptionLabel(option: SortOption): string {
if (option.startsWith('property:')) return option.slice('property:'.length)
return SORT_OPTIONS.find((o) => o.value === option)?.label ?? option
}
/** Extract sortable custom property keys from a list of entries. */
export function extractSortableProperties(entries: VaultEntry[]): string[] {
const keys = new Set<string>()
for (const entry of entries) {
if (entry.properties) {
for (const key of Object.keys(entry.properties)) keys.add(key)
}
}
return [...keys].sort((a, b) => a.localeCompare(b))
}
const STATUS_ORDER: Record<string, number> = {
Active: 0, Paused: 1, Done: 2, Finished: 3,
}
export function getSortComparator(option: SortOption, direction?: SortDirection): (a: VaultEntry, b: VaultEntry) => number {
const dir = direction ?? DEFAULT_DIRECTIONS[option]
const flip = dir === 'asc' ? 1 : -1
switch (option) {
case 'modified':
return (a, b) => flip * ((getDisplayDate(a) ?? 0) - (getDisplayDate(b) ?? 0))
case 'created':
return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0))
case 'title':
return (a, b) => flip * a.title.localeCompare(b.title)
case 'status':
return (a, b) => {
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
if (sa !== sb) return flip * (sa - sb)
// Tiebreaker: always newest first regardless of direction
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}/
function tryParseDate(s: string): number | null {
if (!ISO_DATE_RE.test(s)) return null
const d = new Date(s)
return isNaN(d.getTime()) ? null : d.getTime()
}
function compareNumericPair(a: unknown, b: unknown): number | null {
if (typeof a === 'number' && typeof b === 'number') return a - b
if (typeof a === 'boolean' && typeof b === 'boolean') return (a ? 1 : 0) - (b ? 1 : 0)
return null
}
function comparePropertyValues(a: unknown, b: unknown): number {
const numeric = compareNumericPair(a, b)
if (numeric !== null) return numeric
const sa = String(a)
const sb = String(b)
const da = tryParseDate(sa)
const db = tryParseDate(sb)
if (da !== null && db !== null) return da - db
return sa.localeCompare(sb)
}
function makePropertyComparator(key: string, flip: number): (a: VaultEntry, b: VaultEntry) => number {
return (a, b) => {
const va = a.properties?.[key] ?? null
const vb = b.properties?.[key] ?? null
if (va == null && vb == null) return 0
if (va == null) return 1
if (vb == null) return -1
return flip * comparePropertyValues(va, vb)
}
}
function makeBuiltinComparator(option: string, flip: number): (a: VaultEntry, b: VaultEntry) => number {
if (option === 'title') return (a, b) => flip * a.title.localeCompare(b.title)
if (option === 'created') return (a, b) => flip * ((a.createdAt ?? a.modifiedAt ?? 0) - (b.createdAt ?? b.modifiedAt ?? 0))
if (option === 'status') return (a, b) => {
const sa = STATUS_ORDER[a.status ?? ''] ?? 999
const sb = STATUS_ORDER[b.status ?? ''] ?? 999
if (sa !== sb) return flip * (sa - sb)
return (getDisplayDate(b) ?? 0) - (getDisplayDate(a) ?? 0)
}
return (a, b) => flip * ((getDisplayDate(a) ?? 0) - (getDisplayDate(b) ?? 0))
}
export function getSortComparator(option: SortOption, direction?: SortDirection): (a: VaultEntry, b: VaultEntry) => number {
const flip = (direction ?? getDefaultDirection(option)) === 'asc' ? 1 : -1
if (option.startsWith('property:')) return makePropertyComparator(option.slice('property:'.length), flip)
return makeBuiltinComparator(option, flip)
}
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
/** Serialize a SortConfig to the string format stored in type frontmatter: "option:direction". */
export function serializeSortConfig(config: SortConfig): string {
return `${config.option}:${config.direction}`
}
/** Parse a frontmatter sort string ("option:direction") back to SortConfig. */
export function parseSortConfig(raw: string | null | undefined): SortConfig | null {
if (!raw) return null
// Format: "option:direction" where option itself can contain ":" (e.g. "property:Priority:asc")
const lastColon = raw.lastIndexOf(':')
if (lastColon <= 0) return null
const dir = raw.slice(lastColon + 1)
if (dir !== 'asc' && dir !== 'desc') return null
const option = raw.slice(0, lastColon) as SortOption
return { option, direction: dir }
}
export function loadSortPreferences(): Record<string, SortConfig> {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
@@ -144,7 +210,7 @@ export function loadSortPreferences(): Record<string, SortConfig> {
if (typeof value === 'string') {
// Migrate old format: bare SortOption string → SortConfig
const opt = value as SortOption
result[key] = { option: opt, direction: DEFAULT_DIRECTIONS[opt] }
result[key] = { option: opt, direction: getDefaultDirection(opt) }
} else {
result[key] = value as SortConfig
}
@@ -161,6 +227,21 @@ export function saveSortPreferences(prefs: Record<string, SortConfig>) {
} catch { /* ignore */ }
}
/** Remove the `__list__` key from localStorage sort preferences (used during migration). */
export function clearListSortFromLocalStorage(): void {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
if (!raw) return
const parsed = JSON.parse(raw)
delete parsed['__list__']
if (Object.keys(parsed).length === 0) {
localStorage.removeItem(SORT_STORAGE_KEY)
} else {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(parsed))
}
} catch { /* ignore */ }
}
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
const stem = entity.filename.replace(/\.md$/, '')
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
@@ -220,17 +301,16 @@ export function buildRelationshipGroups(
b.filterAndAdd('Instances', (e) => e.isA === entity.title)
}
b.addFromRefs('Has', rels['Has'] ?? [])
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
b.addFromRefs('Topics', rels['Topics'] ?? [])
const handledKeys = new Set(['Has', 'Topics'])
// Direct relationships first — all keys from entity.relationships take
// priority so that reverse/computed groups (Children, Events, Referenced By)
// only show *additional* entries not already covered by a direct property.
Object.keys(rels)
.filter((k) => !handledKeys.has(k) && k.toLowerCase() !== 'type')
.filter((k) => k.toLowerCase() !== 'type')
.sort((a, b) => a.localeCompare(b))
.forEach((key) => b.addFromRefs(key, rels[key] ?? []))
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
b.add('Backlinks', findBacklinks(entity, allEntries, allContent).sort(sortByModified))

View File

@@ -13,7 +13,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: null, createdAt: null,
fileSize: 0, snippet: '', wordCount: 0, relationships: {}, icon: null, color: null,
order: null, template: null, outgoingLinks: [],
order: null, template: null, sort: null, outgoingLinks: [],
...overrides,
}
}

View File

@@ -54,7 +54,7 @@ const baseEntry: VaultEntry = {
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
wordCount: 0,
icon: null, color: null, order: null, template: null, outgoingLinks: [],
icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
}
describe('buildTypeEntryMap', () => {

View File

@@ -0,0 +1,38 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultOption } from '../components/StatusBar'
export interface PersistedVaultList {
vaults: Array<{ label: string; path: string }>
active_vault: string | null
hidden_defaults: string[]
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
async function checkAvailability(v: { label: string; path: string }): Promise<VaultOption> {
try {
const exists = await tauriCall<boolean>('check_vault_exists', { path: v.path })
return { label: v.label, path: v.path, available: exists }
} catch {
return { label: v.label, path: v.path, available: false }
}
}
export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVault: string | null; hiddenDefaults: string[] }> {
const data = await tauriCall<PersistedVaultList>('load_vault_list', {})
const persisted = data?.vaults ?? []
const checked = await Promise.all(persisted.map(checkAvailability))
return { vaults: checked, activeVault: data?.active_vault ?? null, hiddenDefaults: data?.hidden_defaults ?? [] }
}
export function saveVaultList(vaults: VaultOption[], activeVault: string, hiddenDefaults: string[] = []): Promise<void> {
const list: PersistedVaultList = {
vaults: vaults.map(v => ({ label: v.label, path: v.path })),
active_vault: activeVault,
hidden_defaults: hiddenDefaults,
}
return tauriCall('save_vault_list', { list })
}

Some files were not shown because too many files have changed in this diff Show More