Compare commits

...

81 Commits

Author SHA1 Message Date
Test
816e3ca8bd style: apply cargo fmt to test assertions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:21:47 +01:00
Test
ef148be94e fix: apply rustfmt to claude_cli.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:20:56 +01:00
Test
ceee8b04ea feat: make AI chat tool use blocks expandable with input/output details
Tool call blocks in AI Chat are now clickable and expandable to show
tool name, input parameters (pretty-printed JSON), and output/result.
Collapsed by default, keyboard accessible (Tab/Enter/Space/Esc).

Backend: Rust stream events now carry tool input (accumulated from
input_json_delta chunks) and tool output (from tool_result events).
Frontend: AiActionCard is a disclosure widget with aria-expanded,
error output shown in red, long content truncated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:08:48 +01:00
Test
ba7d2f1acc feat: render markdown in AI Chat assistant responses
Replace regex-based bold/newline rendering with react-markdown + remark-gfm
+ rehype-highlight for full markdown support: bold, code blocks with syntax
highlighting, bullet/ordered lists, headers, blockquotes, links, tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:33:11 +01:00
Test
7e3c8630a4 feat: replace app icon with new Laputa cloud logo 2026-03-03 20:18:51 +01:00
Test
d5b621e174 fix: trash/archive banner appears immediately without reopening note
Tabs stored a snapshot of VaultEntry at open time. When updateEntry()
changed vault.entries (e.g. trashing a note), the active tab's entry
stayed stale, so the banner never appeared until the note was reopened.

Add a useEffect that syncs tab entries with vault.entries whenever the
vault state changes, using reference equality to skip unchanged entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:51:49 +01:00
Test
24c4a5a823 fix: display correct app version as build number in status bar
- build.rs: remove unreliable git rev-list --count logic
- lib.rs: parse build number from Tauri package version at runtime
  - Release version 0.20260303.281 -> 'b281'
  - Dev version 0.1.0 -> 'dev'
- StatusBar.tsx: build number is clickable (triggers check for updates)
- App.tsx: pass handleCheckForUpdates to StatusBar
- Tests: unit tests for parse_build_label + StatusBar click behavior
2026-03-03 19:48:01 +01:00
Test
ea61ded4af fix: use type-only import for DecorationSet (verbatimModuleSyntax)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:40:39 +01:00
Test
3a623d48bb feat: replace raw editor textarea with CodeMirror 6
Adds line numbers, current line highlight, and syntax highlighting
(YAML frontmatter keys/values, --- delimiters, markdown headings).
Extracted useCodeMirror hook and frontmatterHighlight extension.
Preserves wikilink autocomplete, Cmd+S save, and Escape dismiss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:40:39 +01:00
Test
3fcb06396a fix: use git ls-files --unmerged for reliable conflict detection
git diff --name-only --diff-filter=U only works during an active merge
(while MERGE_HEAD exists). When the vault has stale conflict state —
e.g. after a reboot — git diff returns empty, causing conflictFiles=[]
and making the StatusBar click handler, command palette entry, and
conflict resolver modal all non-functional.

Switch to git ls-files --unmerged which reads unmerged index entries
directly and works regardless of MERGE_HEAD state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:36:50 +01:00
Test
17eeac75cd fix: open newly created theme in editor after New Theme command
After createTheme(), the theme file was created and the sidebar navigated
to the Theme section, but the note was never opened in the editor.
Now captures the returned path and opens it via handleSelectNote.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:29:32 +01:00
Test
2dad764ea7 fix: check-for-updates command always visible in Cmd+K, handles all update states 2026-03-03 16:46:47 +01:00
Test
c3fa296b99 refactor: remove AI model indicator from status bar
Remove the hardcoded "Claude Sonnet 4" stub label and unused Sparkles
import — no model picker is planned at this stage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:57:20 +01:00
Test
6370b66e05 fix: handle Escape at panel level and manage focus across active states
Move Escape handler from input onKeyDown to a window-level listener
scoped to the panel, so it works even when input is disabled during
AI response. When agent is active, focus transfers to the panel
container; when idle, focus returns to the input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:34:18 +01:00
Test
1ec27dd264 fix: auto-focus AI Chat input on panel open and close on Escape
When AI Chat panel mounts (via Cmd+I), the input field now receives
focus automatically so users can type immediately without clicking.
Pressing Escape in the input closes the panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:30:12 +01:00
Test
10e6d7b366 fix: handle EADDRINUSE in MCP server ws-bridge — allow Claude Code to start when port is taken
When Claude CLI starts the Laputa MCP server, it crashed immediately because
startUiBridge() tried to bind port 9711 which is already held by the running
Laputa app. The unhandled EADDRINUSE error killed the process, making all
Laputa MCP tools unavailable to Claude Code in AI Chat.

Fix:
- Make startUiBridge() async, return Promise<WebSocketServer|null>
- Handle 'error' event on HTTP server: EADDRINUSE resolves to null instead of crashing
- Guard broadcastUiAction() with 'if (!uiBridge) return' for graceful no-op
- In ws-bridge.js main: chain startUiBridge().then(() => startBridge())

All vault tools (read/write/search) now work via stdio MCP when port is busy.
2026-03-03 13:22:28 +01:00
Test
0c87e51037 feat: add Check for Updates command to native menu and Cmd+K palette
- Add APP_CHECK_FOR_UPDATES constant and menu item in menu.rs (between About and Settings)
- Wire onCheckForUpdates handler through useMenuEvents and useAppCommands
- Add test for app-check-for-updates dispatch
2026-03-03 13:19:09 +01:00
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
Test
db5e53f77e fix: register Cmd+\ keyboard shortcut to toggle raw editor mode
Cmd+\ was not wired to the raw editor toggle. Added onToggleRawEditor
to KeyboardActions interface and mapped '\\' in the cmdKeyMap so the
shortcut fires handleToggleRaw via the existing rawToggleRef.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:02:54 +01:00
Test
90e67adc41 fix: editor background matches vault theme in dark mode
BlockNote's internal CSS sets `.bn-editor { background-color: var(--bn-colors-editor-background); }`
using its own hardcoded variables (#ffffff light, #1f1f1f dark). Our `.bn-container` rule set
`background: var(--bg-primary)` but this didn't cascade to `.bn-editor` which has its own rule.

Override the BlockNote internal CSS variables (`--bn-colors-editor-background` etc.) on
`.bn-container` so BlockNote's own rules pick up our vault theme colors. CSS custom properties
cascade normally, and our selector specificity (2 classes) matches BlockNote's dark theme rule
(1 class + 1 attribute) — source order wins since our CSS loads after BlockNote's.

Fixes: editor content area now seamlessly blends with sidebar and container in any vault theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 19:33:57 +01:00
Test
185feb5a2c feat: raw editor mode — plain textarea with frontmatter + wikilink autocomplete
Adds a third editor mode (alongside WYSIWYG and Diff) that shows the raw
markdown file in a monospaced textarea. Includes:

- useRawMode hook with derived state (no setState-in-effect) and onFlushPending
- RawEditorView: textarea with 500ms debounce, YAML error banner, wikilink
  autocomplete via [[  trigger, Cmd+S save
- BreadcrumbBar Code icon toggles raw mode; mutual exclusion with diff mode
- Command palette: "Toggle Raw Editor" (View group, requires active tab)
- useEditorModeExclusion hook extracted to keep Editor.tsx under complexity threshold
- buildViewCommands extracted from useCommandRegistry for same reason
- RawToggleButton and RawModeEditorSection components extracted for clean CC

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 18:44:47 +01:00
Test
f04dfdbd37 feat: auto-focus editor with H1 title selected on new note creation
When a new note is created (Cmd+N or via command palette), the editor
immediately focuses and selects all text in the H1 title block, so the
user can start typing the note name right away without clicking.

- signalFocusEditor now accepts { selectTitle?: boolean } and passes it
  in the laputa:focus-editor event detail
- handleCreateNoteImmediate dispatches with selectTitle: true
- useEditorFocus reads selectTitle from event and calls selectFirstHeading
- selectFirstHeading walks the ProseMirror document to find the first
  heading node and uses TipTap's chain().setTextSelection() to select it
- Opening existing notes is unaffected (selectTitle defaults to false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 18:31:53 +01:00
Test
3c27403f86 fix: use globalThis instead of global in test setup (TS build compatibility) 2026-03-02 14:45:39 +01:00
Test
276b3c1a37 feat: responsive tab width — shrink tabs to fit window
Tabs now dynamically reduce their max-width when the total width exceeds
the TabBar container, similar to browser tab behavior. Uses a
ResizeObserver on the tab area to calculate per-tab max-width as
min(360, containerWidth / tabCount), floored at 60px minimum.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:45:39 +01:00
Test
cadb3500f1 feat: dark theme applies to editor content area
Three changes make the editor respect the active theme:

1. useThemeManager now derives app-specific CSS variables (--bg-primary,
   --text-primary, --border-primary, etc.) from the theme's core colors,
   so the editor's EditorTheme.css variables resolve correctly for both
   light and dark themes.

2. BlockNoteView theme prop is now dynamic (isDark ? 'dark' : 'light')
   instead of hardcoded to 'light', so BlockNote's own UI chrome (menus,
   toolbars) matches the active theme.

3. Removed forced light-mode class removal from main.tsx that was
   preventing dark mode from being applied.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:35:22 +01:00
Test
ee8f0d6bcd Merge branch 'main' of https://github.com/refactoringhq/laputa-app 2026-03-02 12:04:49 +01:00
Test
41d43501a0 docs: never open PRs — push directly to main always 2026-03-02 12:03:31 +01:00
Luca Rossi
32b8e2ee57 feat: toggle archive/trash shortcuts — Cmd+E unarchives, Cmd+⌫ restores (#175)
Cmd+E and Cmd+Delete now toggle: archiving a normal note or unarchiving
an archived one, trashing a normal note or restoring a trashed one.
Command palette labels update to reflect the current state.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:55:51 +01:00
Luca Rossi
b1110ead87 fix: back/forward nav arrows reopen replaced tabs instead of skipping them (#174)
handleReplaceActiveTab removes the old tab from the tabs array, but the
old path remained in the navigation history. goBack(isTabOpen) then
skipped it because the tab was no longer open, returning null and making
the buttons appear broken.

Fix: filter by vault entry existence (not tab-open state) and reopen
closed tabs via handleSelectNote when navigating back/forward.

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 11:37:20 +01:00
Luca Rossi
b75b917538 fix: use openExternalUrl for release notes link in Tauri app (#171)
window.open() doesn't open URLs in the system browser from Tauri's
WebView. Switch to the existing openExternalUrl utility which uses
@tauri-apps/plugin-opener in native mode.

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 11:36:44 +01:00
Luca Rossi
24bb64841e fix: customize icon & color works for types without Type entry (#173)
Types without a dedicated Type definition note in the vault silently
failed to save icon/color customizations. Both applyCustomization
(Sidebar) and handleCustomizeType (useEntryActions) returned early
when no Type entry existed. Also fixed a race condition where two
concurrent handleUpdateFrontmatter calls could overwrite each other.

Changes:
- useNoteActions: add createTypeEntrySilent for headless Type file creation
- useEntryActions: auto-create Type entry when missing, serialize writes
- Sidebar: applyCustomization proceeds with defaults when no Type entry
- App: wire createTypeEntrySilent into useEntryActions config

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:22:03 +01:00
193 changed files with 10414 additions and 1062 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

@@ -140,6 +140,9 @@ The pre-push hook runs all checks locally before the push goes through. This rep
git push origin main # pre-push hook runs automatically
```
### ⛔ NEVER open a Pull Request
PRs on separate branches diverge from main with every merge, requiring continuous rebases and creating unnecessary conflicts. Always push directly to main. If the push fails (disk full, test failure, etc.) — fix the problem, then push again. There is no scenario where opening a PR is the right fallback.
### ⛔ NEVER use --no-verify
```bash
# FORBIDDEN — will be caught and rejected:

View File

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

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":[],"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

@@ -32,10 +32,16 @@ import { startUiBridge } from './ws-bridge.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions
const uiBridge = startUiBridge(WS_UI_PORT)
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions.
// If the port is already in use (e.g. by the running Laputa app), continue
// without the bridge — vault tools still work via stdio MCP.
let uiBridge = null
startUiBridge(WS_UI_PORT).then((bridge) => {
uiBridge = bridge
})
function broadcastUiAction(action, payload) {
if (!uiBridge) return
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
for (const client of uiBridge.clients) {
if (client.readyState === 1) client.send(msg)

View File

@@ -19,6 +19,7 @@
* Protocol (UI bridge):
* Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." }
*/
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import {
readNote, createNote, searchNotes, appendToNote,
@@ -80,15 +81,34 @@ async function handleMessage(data) {
}
}
/**
* Attempt to start the UI bridge WebSocket server.
* Returns a Promise that resolves to the WebSocketServer or null if the port
* is unavailable (e.g. another Laputa instance owns it).
*/
export function startUiBridge(port = WS_UI_PORT) {
uiBridge = new WebSocketServer({ port })
return new Promise((resolve) => {
const httpServer = createServer()
uiBridge.on('connection', () => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
httpServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`[ws-bridge] UI bridge port ${port} already in use, disabling bridge`)
} else {
console.error(`[ws-bridge] UI bridge error: ${err.message}`)
}
resolve(null)
})
httpServer.listen(port, () => {
const wss = new WebSocketServer({ server: httpServer })
wss.on('connection', () => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
})
uiBridge = wss
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
resolve(wss)
})
})
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
return uiBridge
}
export function startBridge(port = WS_PORT) {
@@ -116,6 +136,5 @@ export function startBridge(port = WS_PORT) {
// Run directly if invoked as main module
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) {
startUiBridge()
startBridge()
startUiBridge().then(() => startBridge())
}

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",
@@ -20,6 +21,12 @@
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.2",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.16",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -48,7 +55,10 @@
"react": "^19.2.0",
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-virtuoso": "^4.18.1",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.1",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
@@ -64,6 +74,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",

1119
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

@@ -1,14 +1,3 @@
fn main() {
let count = std::process::Command::new("git")
.args(["rev-list", "--count", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "DEV".to_string());
println!("cargo:rustc-env=BUILD_NUMBER={}", count);
tauri_build::build()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 KiB

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 665 B

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 881 B

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::BufRead;
use std::path::PathBuf;
use std::process::{Command, Stdio};
@@ -19,9 +20,18 @@ pub enum ClaudeStreamEvent {
/// Incremental text chunk.
TextDelta { text: String },
/// A tool call started (agent mode only).
ToolStart { tool_name: String, tool_id: String },
ToolStart {
tool_name: String,
tool_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
input: Option<String>,
},
/// A tool call finished (agent mode only).
ToolDone { tool_id: String },
ToolDone {
tool_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<String>,
},
/// Final result text + session ID.
Result { text: String, session_id: String },
/// Something went wrong.
@@ -207,6 +217,15 @@ fn build_mcp_config(vault_path: &str) -> Result<String, String> {
serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}"))
}
/// Mutable state accumulated across the JSON stream for a single subprocess.
struct StreamState {
session_id: String,
/// Accumulates `input_json_delta` chunks keyed by tool_use id.
tool_inputs: HashMap<String, String>,
/// The tool_use id of the block currently being streamed.
current_tool_id: Option<String>,
}
/// Core subprocess runner shared by chat and agent modes.
fn run_claude_subprocess<F>(bin: &PathBuf, args: &[String], emit: &mut F) -> Result<String, String>
where
@@ -223,7 +242,11 @@ where
let stdout = child.stdout.take().ok_or("No stdout handle")?;
let reader = std::io::BufReader::new(stdout);
let mut session_id = String::new();
let mut state = StreamState {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
};
for line in reader.lines() {
let line = match line {
@@ -245,7 +268,7 @@ where
Err(_) => continue, // skip non-JSON lines
};
dispatch_event(&json, &mut session_id, emit);
dispatch_event(&json, &mut state, emit);
}
// Read stderr for potential error messages.
@@ -257,7 +280,7 @@ where
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
if !status.success() && session_id.is_empty() {
if !status.success() && state.session_id.is_empty() {
let msg = if stderr_output.contains("not logged in")
|| stderr_output.contains("authentication")
|| stderr_output.contains("auth")
@@ -273,11 +296,11 @@ where
emit(ClaudeStreamEvent::Done);
Ok(session_id)
Ok(state.session_id)
}
/// Parse a single JSON line from the stream and emit the appropriate event.
fn dispatch_event<F>(json: &serde_json::Value, session_id: &mut String, emit: &mut F)
fn dispatch_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
where
F: FnMut(ClaudeStreamEvent),
{
@@ -287,7 +310,7 @@ where
// --- System init → capture session_id ---
"system" if json["subtype"].as_str() == Some("init") => {
if let Some(sid) = json["session_id"].as_str() {
*session_id = sid.to_string();
state.session_id = sid.to_string();
emit(ClaudeStreamEvent::Init {
session_id: sid.to_string(),
});
@@ -296,7 +319,7 @@ where
// --- Streaming partial events (text deltas, tool_use starts) ---
"stream_event" => {
dispatch_stream_event(json, emit);
dispatch_stream_event(json, state, emit);
}
// --- Tool progress (agent mode) ---
@@ -307,6 +330,18 @@ where
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input: None,
});
}
}
// --- Tool result (agent mode) ---
"tool_result" => {
if let Some(id) = json["tool_use_id"].as_str() {
let output = extract_tool_result_text(json);
emit(ClaudeStreamEvent::ToolDone {
tool_id: id.to_string(),
output,
});
}
}
@@ -315,7 +350,7 @@ where
"result" => {
let sid = json["session_id"].as_str().unwrap_or("").to_string();
if !sid.is_empty() {
*session_id = sid.clone();
state.session_id = sid.clone();
}
let text = json["result"].as_str().unwrap_or("").to_string();
emit(ClaudeStreamEvent::Result {
@@ -332,9 +367,11 @@ where
if let (Some(id), Some(name)) =
(block["id"].as_str(), block["name"].as_str())
{
let input = format_tool_input(&block["input"], state, id);
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input,
});
}
}
@@ -347,7 +384,7 @@ where
}
/// Handle a `stream_event` (partial assistant message).
fn dispatch_stream_event<F>(json: &serde_json::Value, emit: &mut F)
fn dispatch_stream_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
where
F: FnMut(ClaudeStreamEvent),
{
@@ -357,29 +394,84 @@ where
match event_type {
"content_block_delta" => {
let delta = &event["delta"];
if delta["type"].as_str() == Some("text_delta") {
if let Some(text) = delta["text"].as_str() {
emit(ClaudeStreamEvent::TextDelta {
text: text.to_string(),
});
match delta["type"].as_str() {
Some("text_delta") => {
if let Some(text) = delta["text"].as_str() {
emit(ClaudeStreamEvent::TextDelta {
text: text.to_string(),
});
}
}
Some("input_json_delta") => {
if let (Some(partial), Some(ref tid)) =
(delta["partial_json"].as_str(), &state.current_tool_id)
{
state
.tool_inputs
.entry(tid.clone())
.or_default()
.push_str(partial);
}
}
_ => {}
}
}
"content_block_start" => {
let block = &event["content_block"];
if block["type"].as_str() == Some("tool_use") {
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
state.current_tool_id = Some(id.to_string());
state.tool_inputs.entry(id.to_string()).or_default();
emit(ClaudeStreamEvent::ToolStart {
tool_name: name.to_string(),
tool_id: id.to_string(),
input: None,
});
}
}
}
"content_block_stop" => {
state.current_tool_id = None;
}
_ => {}
}
}
/// Build the tool input string, preferring accumulated delta chunks over the
/// block's `input` field (which may be empty at stream start).
fn format_tool_input(
block_input: &serde_json::Value,
state: &StreamState,
tool_id: &str,
) -> Option<String> {
if let Some(accumulated) = state.tool_inputs.get(tool_id) {
if !accumulated.is_empty() {
return Some(accumulated.clone());
}
}
if !block_input.is_null() && block_input.as_object().is_some_and(|o| !o.is_empty()) {
return Some(block_input.to_string());
}
None
}
/// Extract displayable text from a `tool_result` event.
fn extract_tool_result_text(json: &serde_json::Value) -> Option<String> {
// String content field
if let Some(s) = json["content"].as_str() {
return Some(s.to_string());
}
// Array of content blocks (Claude format)
if let Some(arr) = json["content"].as_array() {
let texts: Vec<&str> = arr.iter().filter_map(|b| b["text"].as_str()).collect();
if !texts.is_empty() {
return Some(texts.join("\n"));
}
}
// Fallback: "output" field
json["output"].as_str().map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -408,12 +500,20 @@ mod tests {
// --- dispatch_event / dispatch_stream_event ---
fn new_state() -> StreamState {
StreamState {
session_id: String::new(),
tool_inputs: HashMap::new(),
current_tool_id: None,
}
}
/// Run dispatch_event on the given JSON and return (session_id, events).
fn run_dispatch(json: serde_json::Value) -> (String, Vec<ClaudeStreamEvent>) {
let mut sid = String::new();
let mut state = new_state();
let mut events = vec![];
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
(sid, events)
dispatch_event(&json, &mut state, &mut |e| events.push(e));
(state.session_id, events)
}
/// Run dispatch_event with a pre-set session_id.
@@ -421,10 +521,23 @@ mod tests {
json: serde_json::Value,
initial_sid: &str,
) -> (String, Vec<ClaudeStreamEvent>) {
let mut sid = initial_sid.to_string();
let mut state = new_state();
state.session_id = initial_sid.to_string();
let mut events = vec![];
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
(sid, events)
dispatch_event(&json, &mut state, &mut |e| events.push(e));
(state.session_id, events)
}
/// Run multiple dispatch_event calls sharing state (for multi-event sequences).
fn run_dispatch_sequence(
events_json: Vec<serde_json::Value>,
) -> (StreamState, Vec<ClaudeStreamEvent>) {
let mut state = new_state();
let mut events = vec![];
for json in &events_json {
dispatch_event(json, &mut state, &mut |e| events.push(e));
}
(state, events)
}
#[test]
@@ -468,7 +581,7 @@ mod tests {
"event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "tool_abc", "name": "read_note", "input": {} } }
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "read_note" && tool_id == "tool_abc")
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "read_note" && tool_id == "tool_abc")
);
}
@@ -501,7 +614,7 @@ mod tests {
"type": "tool_progress", "tool_name": "search_notes", "tool_use_id": "tool_xyz"
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tool_xyz")
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tool_xyz")
);
}
@@ -523,7 +636,7 @@ mod tests {
}));
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tu_1")
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
);
}
@@ -541,7 +654,8 @@ mod tests {
}
#[test]
fn dispatch_stream_event_non_text_delta_is_ignored() {
fn dispatch_stream_event_input_json_delta_accumulates_silently() {
// input_json_delta doesn't emit events directly — it accumulates in state
let (_, events) = run_dispatch(serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } }
@@ -566,6 +680,112 @@ mod tests {
assert!(events.is_empty());
}
#[test]
fn dispatch_event_handles_tool_result_string_content() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "tool_result",
"tool_use_id": "tool_abc",
"content": "Found 3 notes matching query"
}));
assert_eq!(events.len(), 1);
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolDone { tool_id, output }
if tool_id == "tool_abc" && output.as_deref() == Some("Found 3 notes matching query"))
);
}
#[test]
fn dispatch_event_handles_tool_result_array_content() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "tool_result",
"tool_use_id": "tool_def",
"content": [{ "type": "text", "text": "Line 1" }, { "type": "text", "text": "Line 2" }]
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolDone { output, .. }
if output.as_deref() == Some("Line 1\nLine 2"))
);
}
#[test]
fn dispatch_event_tool_result_missing_tool_id_is_ignored() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "tool_result", "content": "result text"
}));
assert!(events.is_empty());
}
#[test]
fn dispatch_accumulates_input_json_deltas() {
let (_, events) = run_dispatch_sequence(vec![
// Start tool_use block
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "search_notes", "input": {} } }
}),
// Input delta chunks
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "{\"query\":" } }
}),
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "\"test\"}" } }
}),
// Stop block
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_stop" }
}),
// Assistant message triggers ToolStart with accumulated input
serde_json::json!({
"type": "assistant",
"message": { "content": [
{ "type": "tool_use", "id": "t1", "name": "search_notes", "input": { "query": "test" } }
] }
}),
]);
// First event: ToolStart with no input (from content_block_start)
assert!(matches!(
&events[0],
ClaudeStreamEvent::ToolStart { input: None, .. }
));
// Second event: ToolStart with accumulated input (from assistant)
assert!(
matches!(&events[1], ClaudeStreamEvent::ToolStart { input: Some(inp), .. }
if inp == "{\"query\":\"test\"}")
);
}
#[test]
fn dispatch_assistant_uses_block_input_when_no_deltas() {
let (_, events) = run_dispatch(serde_json::json!({
"type": "assistant",
"message": { "content": [
{ "type": "tool_use", "id": "tu_x", "name": "create_note", "input": { "title": "Hello", "content": "world" } }
] }
}));
assert!(
matches!(&events[0], ClaudeStreamEvent::ToolStart { input: Some(inp), .. }
if inp.contains("title") && inp.contains("Hello"))
);
}
#[test]
fn content_block_stop_clears_current_tool() {
let (state, _) = run_dispatch_sequence(vec![
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "x", "input": {} } }
}),
serde_json::json!({
"type": "stream_event",
"event": { "type": "content_block_stop" }
}),
]);
assert!(state.current_tool_id.is_none());
}
// --- run_claude_subprocess with mock scripts ---
#[cfg(unix)]

View File

@@ -427,20 +427,28 @@ pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
}
/// List files with merge conflicts (unmerged paths).
///
/// Uses `git ls-files --unmerged` instead of `git diff --diff-filter=U` because
/// ls-files reliably detects unmerged index entries even when the merge state is
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["diff", "--name-only", "--diff-filter=U"])
.args(["ls-files", "--unmerged"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to check conflicts: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
// Each unmerged file appears multiple times (once per stage: base/ours/theirs).
// Format: "<mode> <hash> <stage>\t<path>"
let mut files: Vec<String> = stdout
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect())
.filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string()))
.collect();
files.sort();
files.dedup();
Ok(files)
}
/// Parse `git pull` output to extract updated file paths.
@@ -461,6 +469,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 +1280,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
@@ -112,14 +116,21 @@ fn git_commit(vault_path: String, message: String) -> Result<String, String> {
git::git_commit(&vault_path, &message)
}
#[tauri::command]
fn get_build_number() -> String {
{
let n = std::env::var("BUILD_NUMBER").unwrap_or_else(|_| "0".to_string());
format!("b{}", n)
fn parse_build_label(version: &str) -> String {
let parts: Vec<&str> = version.split('.').collect();
match parts.as_slice() {
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
[_, _, _] => "dev".to_string(),
_ => "b?".to_string(),
}
}
#[tauri::command]
fn get_build_number(app_handle: tauri::AppHandle) -> String {
let version = app_handle.package_info().version.to_string();
parse_build_label(&version)
}
#[tauri::command]
fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
let vault_path = expand_tilde(&vault_path);
@@ -132,6 +143,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 +230,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 +306,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 +365,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 +458,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 +469,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 +501,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) {
@@ -445,13 +550,36 @@ mod tests {
}
#[test]
fn get_build_number_returns_prefixed_value() {
let result = get_build_number();
assert!(
result.starts_with('b'),
"expected 'b' prefix, got: {}",
result
);
fn parse_build_label_release_version() {
assert_eq!(parse_build_label("0.20260303.281"), "b281");
assert_eq!(parse_build_label("0.20251215.42"), "b42");
}
#[test]
fn parse_build_label_dev_version() {
assert_eq!(parse_build_label("0.1.0"), "dev");
assert_eq!(parse_build_label("0.0.0"), "dev");
}
#[test]
fn parse_build_label_malformed() {
assert_eq!(parse_build_label("invalid"), "b?");
assert_eq!(parse_build_label(""), "b?");
}
}
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 +613,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 +632,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 +641,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 +657,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 +669,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

@@ -23,6 +23,7 @@ const NOTE_TRASH: &str = "note-trash";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
const VIEW_GO_BACK: &str = "view-go-back";
const VIEW_GO_FORWARD: &str = "view-go-forward";
const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
@@ -44,6 +45,7 @@ const CUSTOM_IDS: &[&str] = &[
VIEW_ZOOM_RESET,
VIEW_GO_BACK,
VIEW_GO_FORWARD,
APP_CHECK_FOR_UPDATES,
];
/// IDs of menu items that should be disabled when no note tab is active.
@@ -56,10 +58,15 @@ fn build_app_menu(app: &App) -> MenuResult {
.id(APP_SETTINGS)
.accelerator("CmdOrCtrl+,")
.build(app)?;
let check_updates_item = MenuItemBuilder::new("Check for Updates...")
.id(APP_CHECK_FOR_UPDATES)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Laputa")
.about(None)
.separator()
.item(&check_updates_item)
.separator()
.item(&settings_item)
.separator()
.services()
@@ -269,6 +276,7 @@ mod tests {
"view-zoom-reset",
"view-go-back",
"view-go-forward",
"app-check-for-updates",
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");

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'
@@ -25,15 +24,21 @@ import { useAppCommands } from './hooks/useAppCommands'
import { useDialogs } from './hooks/useDialogs'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater } from './hooks/useUpdater'
import { useUpdater, restartApp } 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,17 +110,63 @@ 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>(() => {})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
// Keep tab entries in sync with vault entries so banners (trash/archive)
// and read-only state react immediately without reopening the note.
useEffect(() => {
notes.setTabs(prev => {
let changed = false
const next = prev.map(tab => {
const fresh = vault.entries.find(e => e.path === tab.entry.path)
if (fresh && fresh !== tab.entry) {
changed = true
return { ...tab, entry: fresh }
}
return tab
})
return changed ? next : prev
})
}, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter)
const navHistory = useNavigationHistory()
// Push to navigation history whenever the active tab changes (user-initiated)
@@ -183,53 +206,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)
@@ -245,8 +248,21 @@ function App() {
entries: vault.entries, updateEntry: vault.updateEntry,
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
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) => {
@@ -269,15 +285,42 @@ function App() {
const bulkActions = useBulkActions(entryActions, setToastMessage)
// 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 () => {
if (updateStatus.state === 'downloading') {
setToastMessage('Update is downloading…')
return
}
if (updateStatus.state === 'ready') {
await restartApp()
return
}
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, updateStatus.state, 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,
@@ -285,10 +328,15 @@ function App() {
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: handleSave,
onOpenSettings: dialogs.openSettings,
onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote,
onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
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,
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
@@ -298,13 +346,29 @@ function App() {
canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward,
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
onSwitchTheme: themeManager.switchTheme,
onCreateTheme: async () => { await themeManager.createTheme() },
onCreateTheme: async () => {
const path = await themeManager.createTheme()
const freshEntries = await vault.reloadVault()
setSelection({ kind: 'sectionGroup', type: 'Theme' })
if (path) {
const entry = freshEntries.find(e => e.path === path)
if (entry) notes.handleSelectNote(entry)
}
},
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,
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
@@ -342,7 +406,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} />
</>
@@ -350,7 +414,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} />
</>
@@ -384,27 +448,43 @@ function App() {
vaultPath={resolvedPath}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onDeleteNote={handleDeleteNote}
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
onRenameTab={handleRenameTab}
onContentChange={handleContentChange}
onSave={handleSave}
onTitleSync={handleTitleSync}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={navHistory.canGoBack}
canGoForward={navHistory.canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
isDarkTheme={themeManager.isDark}
/>
</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} onCheckForUpdates={handleCheckForUpdates} 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

@@ -9,6 +9,7 @@ import {
buildSystemPrompt,
} from '../utils/ai-chat'
import { useAIChat } from '../hooks/useAIChat'
import { MarkdownContent } from './MarkdownContent'
// --- Sub-components ---
@@ -101,10 +102,7 @@ function ContextSearchDropdown({
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
return (
<div>
<div style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap' }}
dangerouslySetInnerHTML={{
__html: msg.content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br/>'),
}} />
<MarkdownContent content={msg.content} />
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
@@ -126,10 +124,7 @@ function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => v
function StreamingContent({ content }: { content: string }) {
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap' }}
dangerouslySetInnerHTML={{
__html: content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br/>'),
}} />
<MarkdownContent content={content} />
</div>
)
}

View File

@@ -2,61 +2,166 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiActionCard } from './AiActionCard'
const defaults = {
tool: 'create_note',
label: 'Creating note... (abc123)',
status: 'done' as const,
expanded: false,
onToggle: vi.fn(),
}
describe('AiActionCard', () => {
it('renders label text', () => {
render(<AiActionCard tool="create_note" label="Created test.md" status="done" />)
render(<AiActionCard {...defaults} label="Created test.md" />)
expect(screen.getByText('Created test.md')).toBeTruthy()
})
it('shows pending spinner', () => {
render(<AiActionCard tool="search_notes" label="Searching..." status="pending" />)
render(<AiActionCard {...defaults} tool="search_notes" label="Searching..." status="pending" />)
expect(screen.getByTestId('status-pending')).toBeTruthy()
})
it('shows done check', () => {
render(<AiActionCard tool="create_note" label="Created" status="done" />)
render(<AiActionCard {...defaults} status="done" />)
expect(screen.getByTestId('status-done')).toBeTruthy()
})
it('shows error icon', () => {
render(<AiActionCard tool="delete_note" label="Failed" status="error" />)
render(<AiActionCard {...defaults} tool="delete_note" label="Failed" status="error" />)
expect(screen.getByTestId('status-error')).toBeTruthy()
})
it('is clickable when path and onOpenNote provided', () => {
it('navigates to note when no details and path+onOpenNote provided', () => {
const onOpenNote = vi.fn()
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={onOpenNote} />)
fireEvent.click(screen.getByTestId('ai-action-card'))
const toggle = vi.fn()
render(
<AiActionCard {...defaults} path="/vault/test.md" onOpenNote={onOpenNote} onToggle={toggle} />,
)
fireEvent.click(screen.getByTestId('action-card-header'))
expect(onOpenNote).toHaveBeenCalledWith('/vault/test.md')
expect(toggle).not.toHaveBeenCalled()
})
it('has button role when clickable', () => {
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={vi.fn()} />)
expect(screen.getByRole('button')).toBeTruthy()
})
it('is not clickable without path', () => {
it('toggles expand instead of navigating when details exist', () => {
const onOpenNote = vi.fn()
render(<AiActionCard tool="create_note" label="Created" status="done" onOpenNote={onOpenNote} />)
fireEvent.click(screen.getByTestId('ai-action-card'))
const toggle = vi.fn()
render(
<AiActionCard
{...defaults}
path="/vault/test.md"
onOpenNote={onOpenNote}
onToggle={toggle}
input='{"title":"test"}'
/>,
)
fireEvent.click(screen.getByTestId('action-card-header'))
expect(toggle).toHaveBeenCalled()
expect(onOpenNote).not.toHaveBeenCalled()
})
it('is not clickable without onOpenNote', () => {
render(<AiActionCard tool="create_note" label="Created" path="/vault/test.md" status="done" />)
const card = screen.getByTestId('ai-action-card')
expect(card.getAttribute('role')).toBeNull()
it('header has button role and is focusable', () => {
render(<AiActionCard {...defaults} />)
const header = screen.getByTestId('action-card-header')
expect(header.getAttribute('role')).toBe('button')
expect(header.getAttribute('tabindex')).toBe('0')
})
it('uses lighter background for ui_ tools', () => {
render(<AiActionCard tool="ui_open_tab" label="Opened tab" status="done" />)
render(<AiActionCard {...defaults} tool="ui_open_tab" label="Opened tab" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.06')
})
it('uses standard background for vault tools', () => {
render(<AiActionCard tool="create_note" label="Created" status="done" />)
render(<AiActionCard {...defaults} />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.1')
})
// --- Expand / collapse ---
it('does not show details when collapsed', () => {
render(<AiActionCard {...defaults} input='{"q":"test"}' output="found 3" expanded={false} />)
expect(screen.queryByTestId('action-card-details')).toBeNull()
})
it('shows details when expanded with input and output', () => {
render(<AiActionCard {...defaults} input='{"q":"test"}' output="found 3" expanded />)
expect(screen.getByTestId('action-card-details')).toBeTruthy()
expect(screen.getByTestId('detail-input')).toBeTruthy()
expect(screen.getByTestId('detail-output')).toBeTruthy()
})
it('shows only input when no output', () => {
render(<AiActionCard {...defaults} input='{"q":"test"}' expanded />)
expect(screen.getByTestId('detail-input')).toBeTruthy()
expect(screen.queryByTestId('detail-output')).toBeNull()
})
it('shows only output when no input', () => {
render(<AiActionCard {...defaults} output="result text" expanded />)
expect(screen.queryByTestId('detail-input')).toBeNull()
expect(screen.getByTestId('detail-output')).toBeTruthy()
})
it('does not show details when expanded but no input or output', () => {
render(<AiActionCard {...defaults} expanded />)
expect(screen.queryByTestId('action-card-details')).toBeNull()
})
it('formats JSON input prettily', () => {
render(<AiActionCard {...defaults} input='{"title":"Hello","content":"world"}' expanded />)
const inputBlock = screen.getByTestId('detail-input')
expect(inputBlock.textContent).toContain('"title": "Hello"')
})
it('truncates very long output', () => {
const longOutput = 'x'.repeat(1000)
render(<AiActionCard {...defaults} output={longOutput} expanded />)
const outputBlock = screen.getByTestId('detail-output')
expect(outputBlock.textContent!.length).toBeLessThan(1000)
})
// --- Keyboard accessibility ---
it('expands on Enter key', () => {
const toggle = vi.fn()
render(<AiActionCard {...defaults} onToggle={toggle} input='{"a":1}' />)
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Enter' })
expect(toggle).toHaveBeenCalled()
})
it('expands on Space key', () => {
const toggle = vi.fn()
render(<AiActionCard {...defaults} onToggle={toggle} input='{"a":1}' />)
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: ' ' })
expect(toggle).toHaveBeenCalled()
})
it('collapses on Escape key when expanded', () => {
const toggle = vi.fn()
render(<AiActionCard {...defaults} onToggle={toggle} expanded input='{"a":1}' />)
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' })
expect(toggle).toHaveBeenCalled()
})
it('does not collapse on Escape when already collapsed', () => {
const toggle = vi.fn()
render(<AiActionCard {...defaults} onToggle={toggle} expanded={false} input='{"a":1}' />)
fireEvent.keyDown(screen.getByTestId('action-card-header'), { key: 'Escape' })
expect(toggle).not.toHaveBeenCalled()
})
it('sets aria-expanded attribute', () => {
const { rerender } = render(<AiActionCard {...defaults} expanded={false} />)
expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('false')
rerender(<AiActionCard {...defaults} expanded />)
expect(screen.getByTestId('action-card-header').getAttribute('aria-expanded')).toBe('true')
})
it('shows error output in red for error status', () => {
render(<AiActionCard {...defaults} status="error" output="Permission denied" expanded />)
const outputBlock = screen.getByTestId('detail-output')
expect(outputBlock.style.color).toContain('destructive')
})
})

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { type KeyboardEvent, type ReactNode, useCallback } from 'react'
import {
PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle,
CircleNotch, CheckCircle, XCircle,
CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown,
} from '@phosphor-icons/react'
export type AiActionStatus = 'pending' | 'done' | 'error'
@@ -11,9 +11,15 @@ export interface AiActionCardProps {
label: string
path?: string
status: AiActionStatus
input?: string
output?: string
expanded: boolean
onToggle: () => void
onOpenNote?: (path: string) => void
}
const MAX_DETAIL_LENGTH = 800
type IconRenderer = (size: number) => ReactNode
const TOOL_ICON_MAP: Record<string, IconRenderer> = {
@@ -43,27 +49,118 @@ function StatusIndicator({ status }: { status: AiActionStatus }) {
return <XCircle size={14} weight="fill" style={{ color: 'var(--destructive)' }} data-testid="status-error" />
}
export function AiActionCard({ tool, label, path, status, onOpenNote }: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
const isClickable = !!path && !!onOpenNote
const isUiTool = tool.startsWith('ui_')
function truncateText(text: string): { text: string; truncated: boolean } {
if (text.length <= MAX_DETAIL_LENGTH) return { text, truncated: false }
return { text: text.slice(0, MAX_DETAIL_LENGTH), truncated: true }
}
function formatInputForDisplay(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
function DetailBlock({ label, content, isError }: {
label: string; content: string; isError?: boolean
}) {
const { text, truncated } = truncateText(content)
return (
<div
className="flex items-center gap-2 rounded"
style={{
padding: '6px 10px',
fontSize: 12,
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
cursor: isClickable ? 'pointer' : 'default',
}}
onClick={isClickable ? () => onOpenNote(path) : undefined}
role={isClickable ? 'button' : undefined}
data-testid="ai-action-card"
>
<span className="shrink-0 text-muted-foreground">{renderIcon(14)}</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
<div style={{ marginTop: 6 }}>
<div
className="text-muted-foreground"
style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', marginBottom: 2 }}
>
{label}
</div>
<pre
data-testid={`detail-${label.toLowerCase()}`}
style={{
fontSize: 11,
lineHeight: 1.4,
margin: 0,
padding: '4px 6px',
borderRadius: 4,
background: 'var(--muted)',
color: isError ? 'var(--destructive)' : 'var(--foreground)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 200,
overflow: 'auto',
}}
>
{text}{truncated && <span className="text-muted-foreground">{'…'}</span>}
</pre>
</div>
)
}
export function AiActionCard({
tool, label, path, status, input, output, expanded, onToggle, onOpenNote,
}: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
const isUiTool = tool.startsWith('ui_')
const hasDetails = !!(input || output)
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
} else if (e.key === 'Escape' && expanded) {
e.preventDefault()
onToggle()
}
}, [onToggle, expanded])
const handleClick = useCallback(() => {
if (path && onOpenNote && !hasDetails) {
onOpenNote(path)
} else {
onToggle()
}
}, [path, onOpenNote, hasDetails, onToggle])
const formattedInput = input ? formatInputForDisplay(input) : undefined
return (
<div
data-testid="ai-action-card"
className="rounded"
style={{
fontSize: 12,
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
}}
>
<div
className="flex items-center gap-2"
style={{ padding: '6px 10px', cursor: 'pointer' }}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={handleClick}
onKeyDown={handleKeyDown}
data-testid="action-card-header"
>
<span className="shrink-0 text-muted-foreground" style={{ width: 14, display: 'flex' }}>
{hasDetails
? (expanded ? <CaretDown size={12} /> : <CaretRight size={12} />)
: renderIcon(14)}
</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
</div>
{expanded && hasDetails && (
<div
data-testid="action-card-details"
style={{ padding: '0 10px 8px 10px' }}
>
{formattedInput && <DetailBlock label="Input" content={formattedInput} />}
{output && (
<DetailBlock label="Output" content={output} isError={status === 'error'} />
)}
</div>
)}
</div>
)
}

View File

@@ -44,8 +44,8 @@ describe('AiMessage', () => {
<AiMessage
userMessage="Do something"
actions={[
{ tool: 'create_note', label: 'Created test.md', status: 'done' },
{ tool: 'search_notes', label: 'Searched', status: 'pending' },
{ tool: 'create_note', toolId: 't1', label: 'Created test.md', status: 'done' },
{ tool: 'search_notes', toolId: 't2', label: 'Searched', status: 'pending' },
]}
/>,
)
@@ -57,11 +57,11 @@ describe('AiMessage', () => {
render(
<AiMessage
userMessage="Do"
actions={[{ tool: 'create_note', label: 'Open', path: '/vault/note.md', status: 'done' }]}
actions={[{ tool: 'create_note', toolId: 't1', label: 'Open', path: '/vault/note.md', status: 'done' }]}
onOpenNote={onOpenNote}
/>,
)
fireEvent.click(screen.getByTestId('ai-action-card'))
fireEvent.click(screen.getByTestId('action-card-header'))
expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md')
})
@@ -88,4 +88,28 @@ describe('AiMessage', () => {
render(<AiMessage userMessage="Ask" actions={[]} />)
expect(screen.queryByTestId('ai-action-card')).toBeNull()
})
it('expands and collapses action cards independently', () => {
render(
<AiMessage
userMessage="Do"
actions={[
{ tool: 'search_notes', toolId: 't1', label: 'Searched', status: 'done', input: '{"q":"test"}', output: 'Found 3' },
{ tool: 'create_note', toolId: 't2', label: 'Created', status: 'done', input: '{"title":"x"}' },
]}
/>,
)
const headers = screen.getAllByTestId('action-card-header')
// Both collapsed initially
expect(screen.queryByTestId('action-card-details')).toBeNull()
// Expand first card
fireEvent.click(headers[0])
expect(screen.getAllByTestId('action-card-details')).toHaveLength(1)
// Expand second card too
fireEvent.click(headers[1])
expect(screen.getAllByTestId('action-card-details')).toHaveLength(2)
// Collapse first card
fireEvent.click(headers[0])
expect(screen.getAllByTestId('action-card-details')).toHaveLength(1)
})
})

View File

@@ -1,12 +1,15 @@
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
import { AiActionCard, type AiActionStatus } from './AiActionCard'
export interface AiAction {
tool: string
toolId: string
label: string
path?: string
status: AiActionStatus
input?: string
output?: string
}
export interface AiMessageProps {
@@ -66,18 +69,25 @@ function ReasoningBlock({ text, expanded, onToggle }: {
)
}
function ActionCardsList({ actions, onOpenNote }: {
actions: AiAction[]; onOpenNote?: (path: string) => void
function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
actions: AiAction[]
onOpenNote?: (path: string) => void
expandedIds: Set<string>
onToggleExpand: (toolId: string) => void
}) {
return (
<div className="flex flex-col gap-1" style={{ marginBottom: 8 }}>
{actions.map((action, i) => (
{actions.map((action) => (
<AiActionCard
key={`${action.tool}-${i}`}
key={action.toolId}
tool={action.tool}
label={action.label}
path={action.path}
status={action.status}
input={action.input}
output={action.output}
expanded={expandedIds.has(action.toolId)}
onToggle={() => onToggleExpand(action.toolId)}
onOpenNote={onOpenNote}
/>
))}
@@ -115,6 +125,16 @@ function StreamingIndicator() {
export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
const [reasoningExpanded, setReasoningExpanded] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
const toggleAction = useCallback((toolId: string) => {
setExpandedActions(prev => {
const next = new Set(prev)
if (next.has(toolId)) next.delete(toolId)
else next.add(toolId)
return next
})
}, [])
return (
<div data-testid="ai-message" style={{ marginBottom: 16 }}>
@@ -126,7 +146,14 @@ export function AiMessage({ userMessage, reasoning, actions, response, isStreami
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
/>
)}
{actions.length > 0 && <ActionCardsList actions={actions} onOpenNote={onOpenNote} />}
{actions.length > 0 && (
<ActionCardsList
actions={actions}
onOpenNote={onOpenNote}
expandedIds={expandedActions}
onToggleExpand={toggleAction}
/>
)}
{response && <ResponseBlock text={response} />}
{isStreaming && !response && <StreamingIndicator />}
</div>

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { AiPanel } from './AiPanel'
import type { VaultEntry } from '../types'
@@ -133,4 +133,33 @@ describe('AiPanel', () => {
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask the AI agent...')
})
it('auto-focuses input on mount', async () => {
vi.useFakeTimers()
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
await act(() => { vi.advanceTimersByTime(1) })
const input = screen.getByTestId('agent-input')
expect(document.activeElement).toBe(input)
vi.useRealTimers()
})
it('calls onClose when Escape is pressed while panel has focus', async () => {
vi.useFakeTimers()
const onClose = vi.fn()
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
await act(() => { vi.advanceTimersByTime(1) })
// Input is focused inside the panel, so Escape should trigger onClose
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
vi.useRealTimers()
})
it('calls onClose when Escape is pressed on panel element', () => {
const onClose = vi.fn()
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
const panel = screen.getByTestId('ai-panel')
panel.focus()
fireEvent.keyDown(panel, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
})

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
@@ -103,10 +103,10 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
)
}
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext }: {
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext, inputRef }: {
input: string; onInputChange: (v: string) => void
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
isActive: boolean; hasContext: boolean
isActive: boolean; hasContext: boolean; inputRef: React.RefObject<HTMLInputElement | null>
}) {
const sendDisabled = isActive || !input.trim()
return (
@@ -116,6 +116,7 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContex
>
<div className="flex items-end gap-2">
<input
ref={inputRef}
value={input}
onChange={e => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
@@ -150,6 +151,8 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContex
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
const [input, setInput] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const panelRef = useRef<HTMLElement>(null)
const linkedEntries = useMemo(() => {
if (!activeEntry || !entries) return []
@@ -163,9 +166,33 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
const agent = useAiAgent(vaultPath, contextPrompt)
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(timer)
}, [])
useEffect(() => {
if (isActive) {
panelRef.current?.focus()
} else {
inputRef.current?.focus()
}
}, [isActive])
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape' && panelRef.current?.contains(document.activeElement)) {
e.preventDefault()
onClose()
}
}, [onClose])
useEffect(() => {
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [handleEscape])
const handleSend = () => {
if (!input.trim() || isActive) return
agent.sendMessage(input)
@@ -181,7 +208,10 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
return (
<aside
ref={panelRef}
tabIndex={-1}
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
style={{ outline: 'none' }}
data-testid="ai-panel"
>
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
@@ -201,6 +231,7 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
onKeyDown={handleKeyDown}
isActive={isActive}
hasContext={hasContext}
inputRef={inputRef}
/>
</aside>
)

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()
})
})

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