Compare commits

...

84 Commits

Author SHA1 Message Date
Test
1cd596061a fix: latest.json must point to .tar.gz not .dmg for Tauri in-app updater
The Tauri updater plugin requires the .app.tar.gz artifact as the update
URL — not the .dmg installer. The DMG is for fresh installs only.
This was causing 'Install Update' to silently fail on all macOS builds
since the auto-updater infrastructure was set up.

Change ARM_DMG → ARM_TARBALL, pointing to the .app.tar.gz artifact.
2026-03-06 10:54:04 +01:00
Test
efb233b18f feat: add Playwright smoke test infrastructure for task-scoped QA
Adds headless Chromium smoke tests that run before push, catching
UI/UX bugs before Brian QA. Includes shared helpers for command
palette and keyboard shortcut testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:39:20 +01:00
Test
fd34df8db0 ci: import Apple certificate before Tauri build so beforeBuildCommand can codesign
bundle-qmd.sh signs qmd/qmd and vec0.dylib with Developer ID + hardened
runtime, but the certificate wasn't in the keychain yet when it ran
(Tauri imports the cert internally, AFTER beforeBuildCommand completes).

Fix: add explicit 'Import Apple Developer certificate' step before the
Tauri build step, using security create-keychain + security import.
This makes the cert available to codesign during beforeBuildCommand.
2026-03-06 09:10:46 +01:00
Test
244deeb727 fix: sign qmd binaries with Developer ID + hardened runtime for notarization
Apple notarization rejected qmd/qmd and qmd/vec0.dylib due to:
- Not signed with valid Developer ID certificate
- Hardened runtime not enabled

Changes:
- bundle-qmd.sh: use APPLE_SIGNING_IDENTITY + --options runtime --timestamp in CI
  (falls back to ad-hoc signing in dev when no identity is set)
- useThemeManager.test.ts: add ensure_vault_themes to mock (added by theme
  editor feature, missing from stale theme ID test mock)
2026-03-06 08:43:54 +01:00
Test
70f94d3a51 docs: add mandatory Playwright Phase 1 QA step before laputa-task-done
Claude Code must now run Playwright smoke tests against the dev server
before firing the done signal. This catches UI/UX bugs (missing Cmd+K
commands, broken shortcuts, layout issues) before Brian's native QA.

- Phase 1 (Playwright, headless) = Claude Code's responsibility
- Phase 2 (native Tauri, keyboard-only) = Brian's responsibility

Phase 1 covers: command palette entries, keyboard shortcuts, Tab
navigation, UI state changes. Phase 2 covers: file system, git, native
Tauri behaviors that can't run in the browser.
2026-03-06 08:36:01 +01:00
Test
bb20ef17f6 fix: bundle qmd source in tools/qmd/ so CI can compile it
bundle-qmd.sh was trying to install qmd via 'bun install -g qmd' which
installs a different public npm package, not Luca's qmd tool. CI runners
(runner user) don't have the local qmd installation.

Fix:
- Copy qmd source (src/, package.json, tsconfig.json, bun.lock) to tools/qmd/
- Update bundle-qmd.sh to prefer tools/qmd/ as QMD_SRC
- Run 'bun install --frozen-lockfile' in QMD_SRC if node_modules missing
- Update sqlite-vec lookup to find packages from node_modules after bun install
- Compilation uses 'cd $QMD_SRC && bun build --compile src/qmd.ts'
- Add tools/ to eslint globalIgnores (qmd source has its own lint standards)
- Local dev machines still work (tools/qmd/ takes priority over global install)
2026-03-06 08:22:20 +01:00
Test
15a1ba6829 ci: add bun setup step to release workflow (required by bundle-qmd.sh)
bundle-qmd.sh uses bun to compile the qmd binary. Release runners
don't have bun pre-installed on self-hosted macOS runners.
Add oven-sh/setup-bun@v2 before the Rust setup step.
2026-03-06 07:23:37 +01:00
Test
e2c6669fd6 fix: align ws-bridge.js imports with simplified vault.js API
After ai-agent-full-shell simplified vault.js to read-only, ws-bridge.js
still imported removed functions (createNote, appendToNote, editNoteFrontmatter,
deleteNote, linkNotes, listNotes, readNote) — breaking CI bundle step.

Fix:
- Import only getNote, searchNotes, vaultContext from vault.js
- Update open_note/read_note handlers to use getNote
- Remove write tool handlers — agent uses native bash/write tools
- Remove orphaned buildFrontmatter helper
2026-03-06 07:17:10 +01:00
Luca Rossi
d4098d3308 refactor: split InspectorPanels.tsx into focused modules — reduce 538-line file to focused per-panel components (#189)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:11:00 +01:00
Luca Rossi
1c3d677851 test: add configMigration tests — cover all migration branches (#188)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:46:10 +01:00
Test
3da0b0e652 fix: make qmd/search work on fresh installs — auto-install, fix permissions, sign binaries
On fresh MacBook installs, the bundled qmd binary fails to run due to:
missing execute permissions, macOS quarantine attributes, and no fallback
when qmd is completely absent. This fix addresses all three issues:

- Runtime: ensure +x permissions and remove quarantine on bundled qmd
- Runtime: auto-install qmd via bun when binary not found anywhere
- Build: ad-hoc code-sign qmd and .dylib files in bundle-qmd.sh
- Build: create placeholder resource dirs so fresh clones build cleanly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:13:03 +01:00
Test
bc2f97d1d4 style: rustfmt search.rs 2026-03-05 21:20:51 +01:00
Test
2fb6a30dff feat: bundle qmd binary with app — search works on fresh installs
Replace the fragile auto-install-via-bun approach with a bundled qmd binary.
The build script (scripts/bundle-qmd.sh) compiles qmd into a standalone
binary using `bun build --compile`, then packages it with sqlite-vec native
extensions and a node-llama-cpp stub for keyword-only search.

Key changes:
- find_qmd_binary() now returns QmdBinary with path + work_dir, checks
  bundled resource first (app bundle and dev mode), then system paths
- All Command::new(qmd_path) calls updated to use QmdBinary::command()
  which sets the correct working directory for node_modules resolution
- Removed auto_install_qmd() and find_bun() — no longer needed
- Tauri config bundles resources/qmd/** into the app

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:20:51 +01:00
Test
0206fb3720 fix: rank command palette results by match score, not section order
When searching in Cmd+K, groups are now ordered by their highest-scoring
match instead of the fixed section order. Empty query preserves the
default section ordering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:00:31 +01:00
Test
51d1b28460 fix: move Go Back/Forward to Go menu, add toggles to Note menu
Move Go Back + Go Forward from View menu to Go menu where they
logically belong. Move Toggle Raw Editor, Toggle AI Chat, and
Toggle Backlinks into the Note menu for better discoverability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:59:23 +01:00
Test
0183062467 fix: always show Resolve Conflicts in Cmd+K — show toast when no conflicts 2026-03-05 18:18:52 +01:00
Test
1c244a85eb refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create

Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.

NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:50:42 +01:00
Test
900755055b style: rustfmt git.rs pulse functions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:29:31 +01:00
Test
3af9a09d29 feat: add Pulse — vault activity feed showing git commit history
Adds a new Pulse sidebar section that shows chronological git commit
history for the vault. Commits are grouped by day with message, time,
short hash (clickable GitHub link when remote configured), file list
with add/modify/delete status icons, and summary badges. Clicking a
file opens the note in the editor. Disabled with tooltip for non-git
vaults. Accessible via sidebar click or "Go to Pulse" command palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:27:29 +01:00
Test
348b2654eb style: apply rustfmt to vault_config.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:30:37 +01:00
Test
013cf0ffe1 feat: move vault UI config from localStorage to vault files
Add VaultConfig infrastructure (store, hook, migration) that persists
zoom, view mode, section visibility, tag/status colors, and property
display modes to config/ui.config.md in the vault instead of localStorage.

- New vaultConfigStore module with subscribe/notify pattern
- useVaultConfig hook loads config via Tauri, binds store, runs migration
- One-time silent migration from localStorage on first load
- Config type excluded from note search and unified search
- All hooks/utils updated to read/write through vault config store
- Tests updated to use vault config store instead of localStorage mocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:26:21 +01:00
Test
418ea8a7a8 feat: add vault_config module and view field to VaultEntry
Rust backend for vault-specific configuration stored as
config/ui.config.md — a regular vault note with YAML frontmatter.
Adds `view` field to VaultEntry for per-type view mode preferences.
Registers get_vault_config and save_vault_config Tauri commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:05:57 +01:00
Test
1e3c296787 fix: pass undefined to useRef for strict TypeScript compat
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:42:28 +01:00
Test
32b4a90ae5 feat: expose all theme.json properties in theme editor
Add ThemePropertyEditor component that surfaces all customizable
properties from theme.json — typography, headings, lists, code blocks,
blockquote, table, and horizontal rule — organized into collapsible
sections with appropriate input types (number, color, select, text).

- themeSchema.ts: derives flat property list from theme.json with
  auto-detected input types, units, and select options
- ThemePropertyEditor.tsx: sectioned editor with collapsible sections,
  keyboard-accessible toggles, debounced live updates
- ThemeManager: add updateThemeProperty() and activeThemeContent
- SettingsPanel: show property editor below theme list when active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:42:28 +01:00
Test
25260c7d58 style: apply rustfmt to menu.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:34:03 +01:00
Test
ff3e7af65a fix: move view-toggle-backlinks to simple event map for type safety
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:34:03 +01:00
Test
75878c8b64 feat: reorganize menu bar with Go, Note, and Vault menus
Add missing command palette commands to menu bar and reorganize into
logical groups: File, Edit, View, Go, Note, Vault, Window. New menus
expose navigation filters, note actions, vault management, themes, and
git operations. Context-sensitive items (archive, trash, diff, raw
editor, commit, conflicts) are greyed out when not applicable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:34:03 +01:00
Test
8f0c6e04fe test: add color detection tests to propertyTypes
Verify hex colors are detected as 'color' display mode, named colors
with color-related keys are detected, and invalid colors are rejected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:57:17 +01:00
Test
adf45a51b5 feat: add color swatch + picker for property values
Add inline color swatch preview next to hex/CSS color property values in
the Properties panel. Clicking the swatch opens the native OS color
picker. Add 'color' property display mode with auto-detection for hex
values and color-related key names (background, primary, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:35:10 +01:00
Test
96bc4e935a fix: enable line wrapping in raw editor
Long lines now wrap to the next visual line instead of scrolling
horizontally off-screen, matching expected behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:57:16 +01:00
Test
eab457b388 fix: make Restore Default Themes command always enabled in Cmd+K palette
The enabled guard `!!onRestoreDefaultThemes` could evaluate to false at
runtime, hiding the command from the palette. Since the handler is always
defined via useCallback, the guard is unnecessary. Changed to enabled: true,
matching the pattern used by other always-available commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:32:32 +01:00
Test
89da970455 feat: enable full shell access for AI agent + simplify MCP tools
Remove --tools "" restriction so the agent has native bash/read/write/edit
access. Set vault path as working directory for the subprocess.

Simplify MCP to 4 Laputa-specific tools (search_notes, get_vault_context,
get_note, open_note) — everything else is handled by native tools.

Add file operation detection from Write/Edit tool calls to auto-open
created notes and refresh modified notes in the UI. Enhanced tool call
labels show bash commands, file paths, and note names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:12:12 +01:00
Test
6a57e83c99 style: rustfmt collect_wikilink_inner signature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:53:15 +01:00
Test
d41e4ea34a fix: strip wikilink brackets from note list preview snippets
[[target]] now shows as "target" and [[target|alias]] shows as "alias"
in note list previews, instead of raw bracket syntax.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:30:43 +01:00
Test
c371e26dcb feat: Add 'Restore Default Themes' command for vaults missing theme structure
- New restore_default_themes() Rust fn: seeds both _themes/ and theme/ dirs
- Per-file idempotent: never overwrites existing files with content
- Fixed ensure_vault_themes() to include minimal.md (was missing)
- New 'Restore Default Themes' command in Cmd+K Appearance group
- 3 new Rust tests + 4 new frontend tests
- 1676 frontend tests passed
2026-03-05 11:18:53 +01:00
Test
c78018b92a fix: rustfmt formatting in git.rs 2026-03-05 10:50:43 +01:00
Test
5e84ebc28a fix: stub WebSocket in test setup to prevent Node 22 + undici crash 2026-03-05 10:49:52 +01:00
Test
5775cb0c96 fix: detect and resolve rebase conflicts in sync conflict resolution
The previous conflict detection only worked for merge-based pulls
(--no-rebase) but failed to detect pre-existing conflicts from
interrupted rebases or prior sessions. This fixes three root causes:

1. Rust: add is_rebase_in_progress/is_merge_in_progress/get_conflict_mode
   helpers, and dispatch git_commit_conflict_resolution between
   `git commit` (merge) and `git rebase --continue` (rebase)
2. Frontend: add startup conflict check via get_conflict_files before
   pulling, so pre-existing conflicts are detected on app launch
3. App.tsx: handleOpenConflictResolver now fetches conflicts directly
   when the cached list is empty, preventing the silent early-return

Also exposes get_conflict_files and get_conflict_mode as Tauri commands
so the frontend can independently check conflict state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:34:25 +01:00
Test
7ced48d001 fix: rustfmt formatting in trash regression tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:02:20 +01:00
Test
533c9da4d0 fix: trashed notes reappear after restart due to frontmatter key casing mismatch
Frontend wrote `trashed` (lowercase) but Rust parser expected `Trashed`
(title-case via serde rename). On restart, the lowercase key didn't match,
defaulted to false, and trashed notes reappeared.

- Frontend: use title-case keys (Trashed, Trashed at) matching vault convention
- Rust: add serde aliases for lowercase keys (backward compat with already-written files)
- Add regression tests for both title-case and lowercase parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:00:56 +01:00
Luca Rossi
e1afaaa5b6 refactor: extract usePropertyPanelState hook into dedicated file (#187)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:11:33 +01:00
Test
914bcfdafd fix: stub fetch in test setup to prevent jsdom@28 + Node 22 undici crash
jsdom@28's JSDOMDispatcher passes an onError handler incompatible with
Node 22's bundled undici, causing InvalidArgumentError (UND_ERR_INVALID_ARG)
on CI. Stubbing globalThis.fetch prevents the dispatcher from being invoked.
The previous uncaughtException handler was insufficient — it caught the wrong
error code and didn't handle unhandled rejections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:30:52 +01:00
Test
cfb047cb22 feat: wikilink pills in message bubbles, noteList context injection
- Render [[wikilink]] reference pills inside sent message bubbles
  with type-colored badges; clicking a pill opens the note
- Add noteList (filtered note list titles, max 100) and noteListFilter
  to the structured context snapshot sent to the AI
- Thread noteList/noteListFilter from App → Editor → EditorRightPanel → AiPanel
- Store references in AiAgentMessage for display in chat history
- Add tests for reference pill rendering and noteList context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:53:46 +01:00
Test
55ff9e6f5d refactor: remove dead useMcpRegistration hook, add design file
useMcpRegistration is fully replaced by useMcpStatus which combines
detection + registration. Added design placeholder for MCP status bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:36:36 +01:00
Test
d3ea632673 feat: detect MCP server status and show warning in status bar
Add check_mcp_status Tauri command that detects whether the MCP server
is installed, Claude CLI is missing, or config needs setup. The status
bar shows a warning badge (MCP ⚠) when not installed, clickable to
trigger install. Also available via command palette "Install MCP Server".

Replaces useMcpRegistration with useMcpStatus which combines detection
and registration in a single hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:11:58 +01:00
Test
6d3d752fd5 fix: pass initial value to useRef for strict TS build
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:21:40 +01:00
Test
26181b57b6 test: add tests for WikilinkChatInput and buildContextSnapshot
- WikilinkChatInput: 18 tests covering menu trigger, filtering, pill creation,
  dedup, removal, keyboard nav, Enter select, send with refs, disabled state
- buildContextSnapshot: 10 tests for structured context JSON output
  including activeNote, openTabs, vault summary, references, frontmatter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:19:23 +01:00
Test
7efcaa11c4 feat: add wikilink autocomplete, animated border, structured context wiring
- WikilinkChatInput: [[ trigger, debounced dropdown, colored type pills, keyboard nav
- AiPanel: uses buildContextSnapshot, WikilinkChatInput, animated blue border
- EditorRightPanel/Editor: thread openTabs to AI panel
- CSS: ai-border-pulse + typing-bounce animations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:15:06 +01:00
Test
197aad0e97 feat: add reasoning streaming, markdown response, structured context snapshot
- Rust: add ThinkingDelta event to ClaudeStreamEvent for reasoning chunks
- ai-agent.ts: forward ThinkingDelta events via onThinking callback
- useAiAgent: stream reasoning live, accumulate response internally,
  reveal as complete block on done
- AiMessage: auto-collapse reasoning when done, use MarkdownContent
  for response rendering, update tests for new behavior
- ai-context: add buildContextSnapshot() for structured JSON context
  with activeNote, openTabs, noteListFilter, vault summary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:09:02 +01:00
Test
db47ffe454 fix: use generic Error type in setup.ts to avoid NodeJS namespace 2026-03-04 11:20:02 +01:00
Test
008f067bf7 fix: restore drag-to-reorder for sidebar sections
Re-add useSortable listeners that were removed in the realignment
refactor. The entire section header row is now the drag target (no
visible handle icon needed). PointerSensor's distance:5 constraint
ensures clicks for collapse/expand don't conflict with drag.

Also suppress pre-existing undici WebSocket ERR_INVALID_ARG_TYPE in
test setup (jsdom Event ≠ Node Event incompatibility).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:07:56 +01:00
Test
55a2509658 fix: MCP UI tools (highlight, open_note) now work in real-time
Root causes:
- index.js tried to start its own UI bridge server on port 9711, but
  ws-bridge.js (spawned by Tauri) already owns it → broadcastUiAction
  was a no-op. Fixed by connecting index.js as a WebSocket CLIENT that
  sends messages through the existing bridge.
- ws-bridge.js UI bridge had no relay — client messages weren't forwarded.
  Added relay so messages from the MCP server reach the React frontend.
- useAiActivity hook existed but was never imported in App.tsx.
- useAiActivity only handled highlight, not open_note/open_tab/set_filter.
- No vault_changed events after write operations.
- set_filter payload used `type` key which overwrote `type: 'ui_action'`.

Changes:
- mcp-server/index.js: connect as WS client instead of starting server;
  broadcast vault_changed after all write operations
- mcp-server/ws-bridge.js: add message relay in UI bridge; broadcast
  vault_changed after write operations; fix set_filter payload key
- useAiActivity: handle all UI actions (highlight, open_note, open_tab,
  set_filter, vault_changed); accept callbacks; auto-reconnect on close
- App.tsx: wire useAiActivity into vault/notes/selection actions; apply
  ai-highlight CSS class to editor and note list panels
- App.css: add ai-highlight-glow keyframe animation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:05:43 +01:00
Test
790f2ea85c style: apply rustfmt to theme.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:35:49 +01:00
Test
ed5e6d6820 fix: auto-provision theme files on vault open for any vault
seed_vault_themes now writes individual missing/empty files instead of
skipping when the theme/ directory already exists. A new
ensure_vault_themes Tauri command is called by useThemeManager on every
vault open so vaults without a theme/ folder get default.md and dark.md
seeded automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:34:51 +01:00
Test
feb97caa87 fix: show 'Installing search...' when qmd missing instead of 'Indexing...'
On fresh installs without qmd, show the accurate "Installing search..."
phase instead of briefly flashing "Indexing..." before switching to
unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:45:24 +01:00
Test
5bcd344d5f fix: replace 'Index error' with graceful handling on fresh installs
Separate 'unavailable' (qmd not installed) from 'error' (indexing failed)
phases. Unavailable state is hidden from the status bar instead of showing
a persistent orange error. Actual errors show "Index failed — retry" with
click-to-retry. Both phases auto-dismiss after a timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:36:10 +01:00
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
222 changed files with 27139 additions and 43387 deletions

View File

@@ -55,6 +55,11 @@ jobs:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun (required for bundle-qmd.sh)
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
@@ -80,6 +85,24 @@ jobs:
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Import Apple Developer certificate into keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
# Decode and import the certificate so codesign can use it in beforeBuildCommand
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -153,7 +176,7 @@ jobs:
REPO="refactoringhq/laputa-app"
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
cat > latest.json << EOF
{
@@ -163,7 +186,7 @@ jobs:
"platforms": {
"darwin-aarch64": {
"signature": "${ARM_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
}
}
}

View File

@@ -47,26 +47,26 @@ fi
# ── 0. TypeScript + Vite build ──────────────────────────────────────────
echo ""
echo "📦 [0/4] TypeScript + Vite build..."
echo "📦 [0/5] TypeScript + Vite build..."
pnpm exec tsc --noEmit
pnpm build
echo " ✅ Build OK"
# ── 1. Frontend coverage (≥70%) — includes all unit tests ───────────────
echo ""
echo "📊 [1/4] Frontend tests + coverage (≥70%)..."
echo "📊 [1/5] Frontend tests + coverage (≥70%)..."
pnpm test:coverage --silent
echo " ✅ Frontend coverage OK"
# ── 2. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
echo ""
if [ "$RUST_CHANGED" = true ]; then
echo "🔧 [2/4] Clippy + rustfmt..."
echo "🔧 [2/5] Clippy + rustfmt..."
cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
echo " ✅ Rust lint OK"
else
echo "⏭️ [2/4] Rust lint — skipped (no src-tauri/ changes)"
echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)"
fi
# ── 3. Rust coverage (≥85% lines) ──────────────────────────────────────
@@ -75,9 +75,9 @@ if [ "$RUST_CHANGED" = true ]; then
LLVM_COV_FLAGS="--no-clean"
if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then
LLVM_COV_FLAGS=""
echo "🦀 [3/4] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
else
echo "🦀 [3/4] Rust coverage (≥85%, incremental)..."
echo "🦀 [3/5] Rust coverage (≥85%, incremental)..."
fi
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
@@ -90,12 +90,23 @@ if [ "$RUST_CHANGED" = true ]; then
-- --test-threads=1
echo " ✅ Rust coverage OK"
else
echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)"
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
fi
# ── 4. CodeScene code health gate (≥9.2) ────────────────────────────────
# ── 4. Playwright smoke tests (if any exist) ──────────────────────────
echo ""
echo "🏥 [4/4] CodeScene code health gate (≥9.2)..."
SMOKE_FILES=$(find tests/smoke -name '*.spec.ts' 2>/dev/null | head -1)
if [ -n "$SMOKE_FILES" ]; then
echo "🎭 [4/5] Playwright smoke tests..."
pnpm playwright:smoke
echo " ✅ Smoke tests OK"
else
echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)"
fi
# ── 5. CodeScene code health gate (≥9.2) ────────────────────────────────
echo ""
echo "🏥 [5/5] CodeScene code health gate (≥9.2)..."
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
else

141
CLAUDE.md
View File

@@ -15,11 +15,40 @@ pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix stru
**CI is a safety net, not a discovery tool.** If CI catches something you didn't catch locally, that's a process failure. All these tools are available locally — use them while you code, not just at the end.
## ⛔ BEFORE FIRING laputa-task-done — QA on real vault
## ⛔ BEFORE FIRING laputa-task-done — Two-phase QA (mandatory)
> **⚠️ TAURI APP ONLY — never test in browser (`pnpm dev` / localhost)**
> The browser dev server has a fake HTTP server (`/api/*` routes, mock handlers) that doesn't exist in the real Tauri app.
> If it works in the browser but crashes in Tauri, it's broken. Always test in `pnpm tauri dev`.
### Phase 1: Playwright browser QA (headless, you do this yourself)
Test every acceptance criterion using Playwright against the dev server **before** marking done. This catches 80% of bugs before Brian sees them.
```bash
# 1. Start the dev server (use your worktree port)
pnpm dev --port <N> &
DEV_PID=$!
sleep 3 # wait for vite to be ready
# 2. Run Playwright smoke test for this task
BASE_URL="http://localhost:<N>" npx playwright test tests/smoke/<slug>.spec.ts
# 3. Or run all smoke tests
BASE_URL="http://localhost:<N>" pnpm playwright:smoke
kill $DEV_PID
```
**What to test in Playwright:**
- Every command palette entry from the spec → open `Cmd+K`, type the command name, verify it appears and executes
- Every keyboard shortcut → send keydown events, verify UI state changes
- Every UI element described in the spec → verify it renders, is focusable, responds to Tab
- Edge cases: empty state, long text, rapid keypresses
**Playwright is non-negotiable even if tests pass.** Unit tests verify code; Playwright verifies the user experience in the real browser. Both are required.
> **⚠️ Browser dev server limits**: the dev server uses mock Tauri handlers (`src/mock-tauri.ts`) — file system operations, git commands, and native dialogs are mocked. Test those via `pnpm tauri dev` in Phase 2 if the task touches them.
### Phase 2: Native Tauri QA (Brian does this after you push)
Brian installs the release build and runs keyboard-only QA on the native app. You don't do Phase 2 — but Phase 1 must pass before you fire the done signal, or Brian's QA will fail and the task goes back to To Rework.
1. Acquire lockfile: `echo $$ > /tmp/laputa-qa.lock && trap "rm -f /tmp/laputa-qa.lock" EXIT`
2. Kill other instances: `pkill -x laputa 2>/dev/null || true; sleep 1`
@@ -29,6 +58,19 @@ pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix stru
6. If task touches file save: verify `git -C ~/Laputa diff` shows changes
7. If QA fails → fix and re-run. Do NOT fire the signal until it passes.
**⚠️ QA ≠ tests. QA means using the app as a user.**
- "Tests pass" is NOT QA. Tests verify code, QA verifies the user experience.
- The QA comment must describe what you did as a user: "Opened app → Cmd+K → typed 'Trash' → pressed Enter → note disappeared from list → restarted app → note still not visible"
- Every QA comment must include: the exact keyboard/command palette steps used, what was visible before and after, and any edge case tested.
- If you cannot test a feature using keyboard only (osascript shortcuts + command palette), the feature is not keyboard-first → QA fails.
**⚠️ Test in a clean environment when the feature depends on state.**
If a feature involves indexing, fresh installs, first-time setup, or anything that only runs once:
- **Do not test in the existing dev vault** — it already has the state you're trying to test.
- **Create a new empty vault** for the test: Cmd+K → "New Vault" (or equivalent), pick a temp folder like `/tmp/test-vault-<slug>`, then test the full first-time flow from scratch.
- This applies to: search indexing, vault init, getting-started setup, any "on first open" logic.
- If you can't reproduce the fresh-install scenario locally, the feature is untestable → do not fire done.
Fire done signal only after QA passes:
```bash
rm -f /tmp/laputa-qa.lock
@@ -74,13 +116,47 @@ Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with
- **Never develop on `main`** — always on `task/<slug>` branch
- **Commit every 2030 min** — atomic commits, one concern per commit (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`)
- **Update docs/** when changing architecture, abstractions, or significant design
- **Test as you go** — write tests alongside code, not after
## Testing
## TDD — Red/Green/Refactor (mandatory)
**Always use test-driven development.** No production code without a failing test first.
The loop:
1. **Red** — write a failing test that describes the behavior you want. Run it, confirm it fails for the right reason.
2. **Green** — write the minimum code to make the test pass. No more, no less.
3. **Refactor** — clean up the code (extract, rename, simplify) while keeping tests green.
4. **Commit** — one red/green/refactor cycle = one atomic commit.
5. Repeat.
**Why this matters:**
- Forces you to think about behavior before implementation
- Produces only code that's actually needed (no speculative abstractions)
- Tests written first are always behavioral and structure-insensitive by construction
- Tiny cycles = fast feedback, smaller diffs, easier to review
**For bug fixes:**
1. Write a failing test that reproduces the bug (this is the regression test)
2. Fix the bug until the test passes
3. Commit both together: `fix: [bug] — regression test added`
**For Rust:**
```bash
cargo watch -x test # run tests on every save
```
**For frontend:**
```bash
pnpm test --watch # run tests on every save
```
**When to deviate:** Pure UI layout/styling work with no logic is the only exception. Everything else — hooks, utilities, Rust commands, state management — must be TDD.
## Testing (quality bar)
- Unit tests must cover real business logic, not "component renders"
- Every bug fixed manually → add a regression test
- Every new feature → new tests for new behavior paths
- Tests test **behavior** (what the code does), not **structure** (how it does it)
- Every bug fixed → regression test that would have caught it
- Every new feature → TDD from the start (see above)
- `pnpm test:coverage` and `cargo llvm-cov` must pass before committing
## Design File (every UI task)
@@ -103,6 +179,33 @@ node -e "const f=JSON.parse(require('fs').readFileSync('ui-design.pen','utf8'));
echo '{"children":[],"variables":{}}' > design/<slug>.pen
```
## Vault File Retrocompatibility (mandatory for every feature that adds vault files)
Laputa vaults are long-lived. New app versions must work on existing vaults that were created before a feature existed.
**Rule: never assume a vault file exists. Always auto-create if missing.**
Every feature that depends on a vault file or folder must:
1. **Auto-bootstrap on vault open** — check if the required file/folder exists; if not, create it with defaults. This must be silent and non-blocking.
2. **Be idempotent** — creating defaults must be safe to run multiple times (never overwrite user data).
3. **Expose a repair command** — add a `Cmd+K` command like "Restore Default Themes" or "Repair Vault Config" that explicitly re-creates missing files. Users can run this if something is broken.
**General "Repair Vault" command** — when adding a new vault file dependency, register it with the central repair system so that `Cmd+K → "Repair Vault"` fixes everything in one shot.
**Pattern:**
```
on vault open:
if file X does not exist → create X with defaults ← silent auto-repair
if file X exists but is malformed → log warning, use defaults (don't crash)
on "Repair Vault" command:
for each known vault file/folder:
if missing → create with defaults
if present → leave untouched (idempotent)
```
This principle applies to: themes, config files, type files, any `.laputa/` subfolder, or any file Laputa expects to find in a vault.
## macOS / Tauri Gotchas
- `Option+N` on macOS → special chars (`¡`, `™`), not `key:'N'`. Use `e.code` or `Cmd+N`.
@@ -118,6 +221,28 @@ bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
bash ~/.openclaw/skills/laputa-qa/scripts/click.sh 400 300 # logical coords
```
## Menu Bar Discoverability (mandatory for every new command)
The command palette is powerful but not discoverable — users must already know a command exists to find it. The macOS menu bar is where users discover what an app can do.
**Rule: every significant command palette entry must also appear in the menu bar.**
When adding a new command to the palette:
1. **Identify the right menu bar group** — File, Edit, View, Note, Vault, or create a new group if needed
2. **Add a menu item** with the same label as the palette command
3. **Show the keyboard shortcut** next to the menu item (if one exists)
4. **If no direct shortcut exists**, still add the menu item — it's discoverable and triggers the same action
The menu bar should be organized around what Laputa does:
- **File** — new note, open vault, switch vault, close
- **Edit** — undo, redo, find, note actions (rename, trash, duplicate)
- **View** — view modes, zoom, sidebar, panels
- **Note** — note-specific actions (move to trash, archive, properties)
- **Vault** — vault management (themes, config, repair, sync)
- **Window / Help** — standard macOS items
**This is a QA requirement:** before marking any task done, verify that every new command palette entry has a corresponding menu bar item.
## Keyboard-First Principle (mandatory for every new feature)
Every feature must be reachable via keyboard. This is both a UX requirement and a QA requirement — Brian tests the native app using keyboard only (osascript key events, no mouse).

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

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

View File

@@ -1,21 +1,15 @@
#!/usr/bin/env node
/**
* Laputa MCP Server — provides vault operation tools for AI assistants.
* Laputa MCP Server — lightweight vault tools for AI agents.
*
* Usage:
* VAULT_PATH=/path/to/vault node index.js
* The agent has full shell access (bash, read, write, edit).
* These MCP tools provide Laputa-specific capabilities that
* native tools cannot replace:
*
* Tools:
* - open_note / read_note: Read a note by path
* - create_note: Create a new note with title and optional frontmatter
* - search_notes: Search notes by title or content
* - append_to_note: Append text to an existing note
* - edit_note_frontmatter: Merge a patch into a note's YAML frontmatter
* - delete_note: Delete a note file
* - link_notes: Add a title to an array property in a note's frontmatter
* - list_notes: List all notes, optionally filtered by type
* - vault_context: Get vault types and recent notes
* - ui_open_note / ui_open_tab / ui_highlight / ui_set_filter: UI actions
* - search_notes: full-text search across vault notes
* - get_vault_context: vault structure overview (types, note count, folders)
* - get_note: parsed frontmatter + content (convenience over raw cat)
* - open_note: signal Laputa UI to open a note as a tab
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
@@ -23,64 +17,47 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import {
readNote, createNote, searchNotes, appendToNote,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
} from './vault.js'
import { startUiBridge } from './ws-bridge.js'
import WebSocket from 'ws'
import { searchNotes, getNote, vaultContext } from './vault.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions
const uiBridge = startUiBridge(WS_UI_PORT)
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
// The bridge relays messages to all other clients (the React frontend).
let uiSocket = null
const RECONNECT_INTERVAL_MS = 3000
function connectUiBridge() {
try {
const ws = new WebSocket(WS_UI_URL)
ws.on('open', () => {
uiSocket = ws
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
})
ws.on('close', () => {
uiSocket = null
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
})
ws.on('error', () => {
// Silent — bridge may not be running yet, will retry
})
} catch {
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
}
}
connectUiBridge()
function broadcastUiAction(action, payload) {
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
for (const client of uiBridge.clients) {
if (client.readyState === 1) client.send(msg)
}
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
}
const TOOLS = [
{
name: 'open_note',
description: 'Open and read a note from the vault by its relative path',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' },
},
required: ['path'],
},
},
{
name: 'read_note',
description: 'Read the full content of a note',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
},
required: ['path'],
},
},
{
name: 'create_note',
description: 'Create a new note in the vault with a title and optional frontmatter',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path for the new note (e.g. "note/my-idea.md")' },
title: { type: 'string', description: 'Title of the note' },
is_a: { type: 'string', description: 'Entity type (Project, Note, Experiment, etc.)' },
},
required: ['path', 'title'],
},
},
{
name: 'search_notes',
description: 'Search notes in the vault by title or content',
description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.',
inputSchema: {
type: 'object',
properties: {
@@ -91,72 +68,24 @@ const TOOLS = [
},
},
{
name: 'append_to_note',
description: 'Append text to the end of an existing note',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
text: { type: 'string', description: 'Text to append' },
},
required: ['path', 'text'],
},
},
{
name: 'edit_note_frontmatter',
description: 'Merge a patch object into a note\'s YAML frontmatter',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
patch: { type: 'object', description: 'Key-value pairs to merge into frontmatter' },
},
required: ['path', 'patch'],
},
},
{
name: 'delete_note',
description: 'Delete a note file from the vault',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note to delete' },
},
required: ['path'],
},
},
{
name: 'link_notes',
description: 'Add a target title to an array property in a note\'s frontmatter (e.g. add "Marco" to people: [])',
inputSchema: {
type: 'object',
properties: {
source_path: { type: 'string', description: 'Relative path to the source note' },
property: { type: 'string', description: 'Frontmatter property name (e.g. "people", "tags")' },
target_title: { type: 'string', description: 'Title to add to the array' },
},
required: ['source_path', 'property', 'target_title'],
},
},
{
name: 'list_notes',
description: 'List all notes in the vault, optionally filtered by type frontmatter field',
inputSchema: {
type: 'object',
properties: {
type_filter: { type: 'string', description: 'Filter by type frontmatter value' },
sort: { type: 'string', enum: ['title', 'mtime'], description: 'Sort order (default: title)' },
},
},
},
{
name: 'vault_context',
description: 'Get vault context: unique entity types and 20 most recently modified notes',
name: 'get_vault_context',
description: 'Get vault orientation: entity types, total note count, top-level folders, and 20 most recently modified notes.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'ui_open_note',
description: 'Open a note in the Laputa UI editor',
name: 'get_note',
description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' },
},
required: ['path'],
},
},
{
name: 'open_note',
description: 'Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.',
inputSchema: {
type: 'object',
properties: {
@@ -165,69 +94,13 @@ const TOOLS = [
required: ['path'],
},
},
{
name: 'ui_open_tab',
description: 'Open a note in a new tab in the Laputa UI',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
},
required: ['path'],
},
},
{
name: 'ui_highlight',
description: 'Highlight a UI element in the Laputa interface',
inputSchema: {
type: 'object',
properties: {
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'UI element to highlight' },
path: { type: 'string', description: 'Relative path to the note (optional)' },
},
required: ['element'],
},
},
{
name: 'ui_set_filter',
description: 'Set the sidebar filter to show notes of a specific type',
inputSchema: {
type: 'object',
properties: {
type: { type: 'string', description: 'Type to filter by' },
},
required: ['type'],
},
},
]
const TOOL_HANDLERS = {
open_note: handleReadNote,
read_note: handleReadNote,
create_note: handleCreateNote,
search_notes: handleSearchNotes,
append_to_note: handleAppendToNote,
edit_note_frontmatter: handleEditFrontmatter,
delete_note: handleDeleteNote,
link_notes: handleLinkNotes,
list_notes: handleListNotes,
vault_context: handleVaultContext,
ui_open_note: handleUiOpenNote,
ui_open_tab: handleUiOpenTab,
ui_highlight: handleUiHighlight,
ui_set_filter: handleUiSetFilter,
}
async function handleReadNote(args) {
const content = await readNote(VAULT_PATH, args.path)
return { content: [{ type: 'text', text: content }] }
}
async function handleCreateNote(args) {
const frontmatter = {}
if (args.is_a) frontmatter.is_a = args.is_a
const absPath = await createNote(VAULT_PATH, args.path, args.title, frontmatter)
return { content: [{ type: 'text', text: `Created note at ${absPath}` }] }
get_vault_context: handleVaultContext,
get_note: handleGetNote,
open_note: handleOpenNote,
}
async function handleSearchNotes(args) {
@@ -238,63 +111,25 @@ async function handleSearchNotes(args) {
return { content: [{ type: 'text', text }] }
}
async function handleAppendToNote(args) {
await appendToNote(VAULT_PATH, args.path, args.text)
return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] }
}
async function handleEditFrontmatter(args) {
const updated = await editNoteFrontmatter(VAULT_PATH, args.path, args.patch)
return { content: [{ type: 'text', text: JSON.stringify(updated) }] }
}
async function handleDeleteNote(args) {
await deleteNote(VAULT_PATH, args.path)
return { content: [{ type: 'text', text: `Deleted ${args.path}` }] }
}
async function handleLinkNotes(args) {
const arr = await linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title)
return { content: [{ type: 'text', text: `${args.property}: [${arr.join(', ')}]` }] }
}
async function handleListNotes(args) {
const notes = await listNotes(VAULT_PATH, args.type_filter, args.sort)
const text = notes.length === 0
? 'No notes found.'
: notes.map(n => `${n.title} (${n.path})${n.type ? ` [${n.type}]` : ''}`).join('\n')
return { content: [{ type: 'text', text }] }
}
async function handleVaultContext() {
const ctx = await vaultContext(VAULT_PATH)
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
}
function handleUiOpenNote(args) {
broadcastUiAction('open_note', { path: args.path })
return { content: [{ type: 'text', text: `Opening ${args.path} in UI` }] }
async function handleGetNote(args) {
const note = await getNote(VAULT_PATH, args.path)
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
}
function handleUiOpenTab(args) {
function handleOpenNote(args) {
broadcastUiAction('open_tab', { path: args.path })
return { content: [{ type: 'text', text: `Opening tab for ${args.path}` }] }
}
function handleUiHighlight(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
}
function handleUiSetFilter(args) {
broadcastUiAction('set_filter', { type: args.type })
return { content: [{ type: 'text', text: `Filter set to ${args.type}` }] }
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
}
// --- Server setup ---
const server = new Server(
{ name: 'laputa-mcp-server', version: '0.1.0' },
{ name: 'laputa-mcp-server', version: '0.2.0' },
{ capabilities: { tools: {} } },
)

View File

@@ -1,5 +1,6 @@
/**
* Vault operations — file I/O for Laputa markdown vault.
* Vault operations — read-only helpers for Laputa markdown vault.
* Write operations are handled by the agent's native bash/write/edit tools.
*/
import fs from 'node:fs/promises'
import path from 'node:path'
@@ -26,36 +27,20 @@ export async function findMarkdownFiles(dir) {
}
/**
* Read a note's content by path (absolute or relative to vault).
* Read a note with parsed frontmatter and content.
* @param {string} vaultPath
* @param {string} notePath
* @returns {Promise<string>}
* @returns {Promise<{path: string, frontmatter: Record<string, unknown>, content: string}>}
*/
export async function readNote(vaultPath, notePath) {
export async function getNote(vaultPath, notePath) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
return fs.readFile(absPath, 'utf-8')
}
/**
* Create a new note with optional frontmatter.
* @param {string} vaultPath
* @param {string} relativePath
* @param {string} title
* @param {Record<string, string>} [frontmatter]
* @returns {Promise<string>} The absolute path of the created file.
*/
export async function createNote(vaultPath, relativePath, title, frontmatter = {}) {
const absPath = path.join(vaultPath, relativePath)
await fs.mkdir(path.dirname(absPath), { recursive: true })
const fmEntries = { title, ...frontmatter }
const fmLines = Object.entries(fmEntries)
.map(([k, v]) => `${k}: ${v}`)
.join('\n')
const content = `---\n${fmLines}\n---\n\n# ${title}\n\n`
await fs.writeFile(absPath, content, 'utf-8')
return absPath
const raw = await fs.readFile(absPath, 'utf-8')
const parsed = matter(raw)
return {
path: path.relative(vaultPath, absPath),
frontmatter: parsed.data,
content: parsed.content.trim(),
}
}
/**
@@ -92,110 +77,14 @@ export async function searchNotes(vaultPath, query, limit = 10) {
}
/**
* Append text to the end of a note.
* Get vault context: unique types, note count, top-level folders, and 20 most recent notes.
* @param {string} vaultPath
* @param {string} notePath
* @param {string} text
* @returns {Promise<void>}
*/
export async function appendToNote(vaultPath, notePath, text) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
const current = await fs.readFile(absPath, 'utf-8')
const separator = current.endsWith('\n') ? '\n' : '\n\n'
await fs.writeFile(absPath, current + separator + text + '\n', 'utf-8')
}
/**
* Merge a patch object into a note's YAML frontmatter.
* @param {string} vaultPath
* @param {string} notePath
* @param {Record<string, unknown>} patch
* @returns {Promise<Record<string, unknown>>} The updated frontmatter.
*/
export async function editNoteFrontmatter(vaultPath, notePath, patch) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
const raw = await fs.readFile(absPath, 'utf-8')
const parsed = matter(raw)
Object.assign(parsed.data, patch)
const updated = matter.stringify(parsed.content, parsed.data)
await fs.writeFile(absPath, updated, 'utf-8')
return parsed.data
}
/**
* Delete a note file.
* @param {string} vaultPath
* @param {string} notePath
* @returns {Promise<void>}
*/
export async function deleteNote(vaultPath, notePath) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
await fs.unlink(absPath)
}
/**
* Add a target title to an array property in a note's frontmatter.
* Creates the property as an array if it doesn't exist.
* @param {string} vaultPath
* @param {string} sourcePath
* @param {string} property
* @param {string} targetTitle
* @returns {Promise<string[]>} The updated array.
*/
export async function linkNotes(vaultPath, sourcePath, property, targetTitle) {
const absPath = path.isAbsolute(sourcePath) ? sourcePath : path.join(vaultPath, sourcePath)
const raw = await fs.readFile(absPath, 'utf-8')
const parsed = matter(raw)
const current = Array.isArray(parsed.data[property]) ? parsed.data[property] : []
if (!current.includes(targetTitle)) {
current.push(targetTitle)
}
parsed.data[property] = current
const updated = matter.stringify(parsed.content, parsed.data)
await fs.writeFile(absPath, updated, 'utf-8')
return current
}
/**
* List all notes in the vault, optionally filtered by type.
* @param {string} vaultPath
* @param {string} [typeFilter]
* @param {string} [sort] - 'title' or 'mtime' (default: 'title')
* @returns {Promise<Array<{path: string, title: string, type: string|null}>>}
*/
export async function listNotes(vaultPath, typeFilter, sort = 'title') {
const files = await findMarkdownFiles(vaultPath)
const notes = await Promise.all(files.map(async (filePath) => {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const relativePath = path.relative(vaultPath, filePath)
const title = parsed.data.title || extractTitle(raw, path.basename(filePath, '.md'))
const type = parsed.data.type || parsed.data.is_a || null
const stat = sort === 'mtime' ? await fs.stat(filePath) : null
return { path: relativePath, title, type, mtime: stat?.mtimeMs ?? 0 }
}))
const filtered = typeFilter
? notes.filter(n => n.type === typeFilter)
: notes
if (sort === 'mtime') {
filtered.sort((a, b) => b.mtime - a.mtime)
} else {
filtered.sort((a, b) => a.title.localeCompare(b.title))
}
return filtered.map(({ mtime: _mtime, ...rest }) => rest)
}
/**
* Get vault context: unique types and 20 most recent notes.
* @param {string} vaultPath
* @returns {Promise<{types: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>}
* @returns {Promise<{types: string[], noteCount: number, folders: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>}
*/
export async function vaultContext(vaultPath) {
const files = await findMarkdownFiles(vaultPath)
const typesSet = new Set()
const foldersSet = new Set()
const notesWithMtime = []
for (const filePath of files) {
@@ -203,9 +92,12 @@ export async function vaultContext(vaultPath) {
const parsed = matter(raw)
const type = parsed.data.type || parsed.data.is_a || null
if (type) typesSet.add(type)
const rel = path.relative(vaultPath, filePath)
const topFolder = rel.split(path.sep)[0]
if (topFolder !== rel) foldersSet.add(topFolder + '/')
const stat = await fs.stat(filePath)
notesWithMtime.push({
path: path.relative(vaultPath, filePath),
path: rel,
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
@@ -215,7 +107,13 @@ export async function vaultContext(vaultPath) {
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
return { types: [...typesSet].sort(), recentNotes, vaultPath }
return {
types: [...typesSet].sort(),
noteCount: files.length,
folders: [...foldersSet].sort(),
recentNotes,
vaultPath,
}
}
// --- Helpers ---

View File

@@ -19,10 +19,10 @@
* 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,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
getNote, searchNotes, vaultContext,
} from './vault.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
@@ -40,27 +40,16 @@ function broadcastUiAction(action, payload) {
}
}
function buildFrontmatter(args) {
const fm = {}
if (args.is_a) fm.is_a = args.is_a
return fm
}
const TOOL_HANDLERS = {
open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
read_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)),
open_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })),
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch),
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => ({ ok: true })),
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title),
list_notes: (args) => listNotes(VAULT_PATH, args.type_filter, args.sort),
vault_context: () => vaultContext(VAULT_PATH),
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { type: args.type }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
}
async function handleMessage(data) {
@@ -80,15 +69,41 @@ 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', (ws) => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
// Relay: when a client sends a message, broadcast to all OTHER clients.
// This allows the MCP stdio server (connected as a client) to reach the frontend.
ws.on('message', (raw) => {
for (const client of wss.clients) {
if (client !== ws && client.readyState === 1) client.send(raw.toString())
}
})
})
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 +131,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

@@ -13,6 +13,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test tests/smoke/",
"test:coverage": "vitest run --coverage",
"prepare": "husky"
},
@@ -21,6 +22,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",
@@ -49,7 +56,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"

View File

@@ -1,15 +1,18 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30000,
testDir: './tests/smoke',
timeout: 15_000,
retries: 0,
workers: 1,
use: {
baseURL: 'http://localhost:5173',
baseURL: process.env.BASE_URL || 'http://localhost:5201',
headless: true,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {
command: 'pnpm dev',
port: 5173,
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`,
url: process.env.BASE_URL || 'http://localhost:5201',
reuseExistingServer: true,
},
})

411
pnpm-lock.yaml generated
View File

@@ -20,6 +20,24 @@ importers:
'@blocknote/react':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@codemirror/commands':
specifier: ^6.10.2
version: 6.10.2
'@codemirror/lang-markdown':
specifier: ^6.5.0
version: 6.5.0
'@codemirror/lang-yaml':
specifier: ^6.1.2
version: 6.1.2
'@codemirror/language':
specifier: ^6.12.2
version: 6.12.2
'@codemirror/state':
specifier: ^6.5.4
version: 6.5.4
'@codemirror/view':
specifier: ^6.39.16
version: 6.39.16
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -104,9 +122,18 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.4(react@19.2.4)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
react-virtuoso:
specifier: ^4.18.1
version: 4.18.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
tailwind-merge:
specifier: ^3.4.1
version: 3.4.1
@@ -336,6 +363,39 @@ packages:
react: ^18.0 || ^19.0 || >= 19.0.0-rc
react-dom: ^18.0 || ^19.0 || >= 19.0.0-rc
'@codemirror/autocomplete@6.20.1':
resolution: {integrity: sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==}
'@codemirror/commands@6.10.2':
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-html@6.4.11':
resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==}
'@codemirror/lang-javascript@6.2.5':
resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
'@codemirror/lang-markdown@6.5.0':
resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==}
'@codemirror/lang-yaml@6.1.2':
resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==}
'@codemirror/language@6.12.2':
resolution: {integrity: sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==}
'@codemirror/lint@6.9.5':
resolution: {integrity: sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==}
'@codemirror/state@6.5.4':
resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==}
'@codemirror/view@6.39.16':
resolution: {integrity: sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==}
'@csstools/color-helpers@6.0.1':
resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==}
engines: {node: '>=20.19.0'}
@@ -664,6 +724,30 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@lezer/common@1.5.1':
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
'@lezer/css@1.3.1':
resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==}
'@lezer/highlight@1.2.3':
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
'@lezer/html@1.3.13':
resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==}
'@lezer/javascript@1.5.4':
resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
'@lezer/lr@1.4.8':
resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==}
'@lezer/markdown@1.6.3':
resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
'@lezer/yaml@1.0.4':
resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==}
'@mantine/core@8.3.14':
resolution: {integrity: sha512-ZOxggx65Av1Ii1NrckCuqzluRpmmG+8DyEw24wDom3rmwsPg9UV+0le2QTyI5Eo60LzPfUju1KuEPiUzNABIPg==}
peerDependencies:
@@ -681,6 +765,9 @@ packages:
peerDependencies:
react: '>=16.8.0'
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
'@modelcontextprotocol/sdk@1.27.1':
resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
engines: {node: '>=18'}
@@ -1906,6 +1993,9 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1941,6 +2031,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -2191,6 +2284,9 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -2429,6 +2525,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@@ -2621,6 +2720,9 @@ packages:
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
hast-util-to-mdast@10.1.2:
resolution: {integrity: sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ==}
@@ -2654,6 +2756,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -2704,6 +2809,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
ip-address@10.0.1:
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
engines: {node: '>= 12'}
@@ -2712,6 +2820,15 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-extendable@0.1.1:
resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
engines: {node: '>=0.10.0'}
@@ -2724,6 +2841,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
@@ -2985,6 +3105,15 @@ packages:
mdast-util-gfm@3.1.0:
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
mdast-util-mdx-expression@2.0.1:
resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==}
mdast-util-mdx-jsx@3.2.0:
resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==}
mdast-util-mdxjs-esm@2.0.1:
resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==}
mdast-util-phrasing@4.1.0:
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
@@ -3169,6 +3298,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@@ -3378,6 +3510,12 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-number-format@5.4.4:
resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==}
peerDependencies:
@@ -3441,6 +3579,9 @@ packages:
rehype-format@5.0.1:
resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==}
rehype-highlight@7.0.2:
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
rehype-minify-whitespace@6.0.2:
resolution: {integrity: sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw==}
@@ -3581,6 +3722,15 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
style-mod@4.1.3:
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -4220,6 +4370,96 @@ snapshots:
- sugar-high
- supports-color
'@codemirror/autocomplete@6.20.1':
dependencies:
'@codemirror/language': 6.12.2
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@lezer/common': 1.5.1
'@codemirror/commands@6.10.2':
dependencies:
'@codemirror/language': 6.12.2
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@lezer/common': 1.5.1
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.1
'@codemirror/language': 6.12.2
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.1
'@lezer/css': 1.3.1
'@codemirror/lang-html@6.4.11':
dependencies:
'@codemirror/autocomplete': 6.20.1
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.2
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@lezer/common': 1.5.1
'@lezer/css': 1.3.1
'@lezer/html': 1.3.13
'@codemirror/lang-javascript@6.2.5':
dependencies:
'@codemirror/autocomplete': 6.20.1
'@codemirror/language': 6.12.2
'@codemirror/lint': 6.9.5
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@lezer/common': 1.5.1
'@lezer/javascript': 1.5.4
'@codemirror/lang-markdown@6.5.0':
dependencies:
'@codemirror/autocomplete': 6.20.1
'@codemirror/lang-html': 6.4.11
'@codemirror/language': 6.12.2
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@lezer/common': 1.5.1
'@lezer/markdown': 1.6.3
'@codemirror/lang-yaml@6.1.2':
dependencies:
'@codemirror/autocomplete': 6.20.1
'@codemirror/language': 6.12.2
'@codemirror/state': 6.5.4
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/yaml': 1.0.4
'@codemirror/language@6.12.2':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
style-mod: 4.1.3
'@codemirror/lint@6.9.5':
dependencies:
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.16
crelt: 1.0.6
'@codemirror/state@6.5.4':
dependencies:
'@marijn/find-cluster-break': 1.0.2
'@codemirror/view@6.39.16':
dependencies:
'@codemirror/state': 6.5.4
crelt: 1.0.6
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@csstools/color-helpers@6.0.1': {}
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
@@ -4464,6 +4704,45 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@lezer/common@1.5.1': {}
'@lezer/css@1.3.1':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/highlight@1.2.3':
dependencies:
'@lezer/common': 1.5.1
'@lezer/html@1.3.13':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/javascript@1.5.4':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@lezer/lr@1.4.8':
dependencies:
'@lezer/common': 1.5.1
'@lezer/markdown@1.6.3':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/yaml@1.0.4':
dependencies:
'@lezer/common': 1.5.1
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.8
'@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -4486,6 +4765,8 @@ snapshots:
dependencies:
react: 19.2.4
'@marijn/find-cluster-break@1.0.2': {}
'@modelcontextprotocol/sdk@1.27.1(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.9(hono@4.12.3)
@@ -5683,6 +5964,10 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
'@types/estree@1.0.8': {}
'@types/hast@3.0.4':
@@ -5718,6 +6003,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
'@types/use-sync-external-store@0.0.6': {}
@@ -6017,6 +6304,8 @@ snapshots:
character-entities@2.0.2: {}
character-reference-invalid@2.0.1: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -6266,6 +6555,8 @@ snapshots:
estraverse@5.3.0: {}
estree-util-is-identifier-name@3.0.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
@@ -6515,6 +6806,26 @@ snapshots:
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.8
'@types/hast': 3.0.4
'@types/unist': 3.0.3
comma-separated-tokens: 2.0.3
devlop: 1.1.0
estree-util-is-identifier-name: 3.0.0
hast-util-whitespace: 3.0.0
mdast-util-mdx-expression: 2.0.1
mdast-util-mdx-jsx: 3.2.0
mdast-util-mdxjs-esm: 2.0.1
property-information: 7.1.0
space-separated-tokens: 2.0.2
style-to-js: 1.1.21
unist-util-position: 5.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
hast-util-to-mdast@10.1.2:
dependencies:
'@types/hast': 3.0.4
@@ -6569,6 +6880,8 @@ snapshots:
html-escaper@2.0.2: {}
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
html-whitespace-sensitive-tag-names@3.0.1: {}
@@ -6616,10 +6929,21 @@ snapshots:
inherits@2.0.4: {}
inline-style-parser@0.2.7: {}
ip-address@10.0.1: {}
ipaddr.js@1.9.1: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
is-decimal@2.0.1: {}
is-extendable@0.1.1: {}
is-extglob@2.1.1: {}
@@ -6628,6 +6952,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hexadecimal@2.0.1: {}
is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
@@ -6921,6 +7247,45 @@ snapshots:
transitivePeerDependencies:
- supports-color
mdast-util-mdx-expression@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-mdx-jsx@3.2.0:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
unist-util-stringify-position: 4.0.0
vfile-message: 4.0.3
transitivePeerDependencies:
- supports-color
mdast-util-mdxjs-esm@2.0.1:
dependencies:
'@types/estree-jsx': 1.0.5
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
devlop: 1.1.0
mdast-util-from-markdown: 2.0.2
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
mdast-util-phrasing@4.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -7216,6 +7581,16 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
character-entities-legacy: 3.0.0
character-reference-invalid: 2.0.1
decode-named-character-reference: 1.3.0
is-alphanumerical: 2.0.1
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse5@7.3.0:
dependencies:
entities: 6.0.1
@@ -7481,6 +7856,24 @@ snapshots:
react-is@17.0.2: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@types/hast': 3.0.4
'@types/mdast': 4.0.4
'@types/react': 19.2.14
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.4
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-number-format@5.4.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
@@ -7541,6 +7934,14 @@ snapshots:
'@types/hast': 3.0.4
hast-util-format: 1.1.0
rehype-highlight@7.0.2:
dependencies:
'@types/hast': 3.0.4
hast-util-to-text: 4.0.2
lowlight: 3.3.0
unist-util-visit: 5.1.0
vfile: 6.0.3
rehype-minify-whitespace@6.0.2:
dependencies:
'@types/hast': 3.0.4
@@ -7752,6 +8153,16 @@ snapshots:
strip-json-comments@3.1.1: {}
style-mod@4.1.3: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
style-to-object@1.0.14:
dependencies:
inline-style-parser: 0.2.7
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0

155
scripts/bundle-qmd.sh Executable file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Bundle qmd into a self-contained directory for Tauri resource embedding.
#
# Output: src-tauri/resources/qmd/
# qmd — compiled standalone binary
# node_modules/sqlite-vec/ — JS shim for sqlite-vec
# node_modules/sqlite-vec-darwin-arm64/ — native .dylib (arm64)
# node_modules/sqlite-vec-darwin-x64/ — native .dylib (x64)
# node_modules/node-llama-cpp/ — stub (keyword search only)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$SCRIPT_DIR/.."
OUT="$ROOT/src-tauri/resources/qmd"
# ---------- locate tools ----------
find_bun() {
for c in \
"$HOME/.bun/bin/bun" \
"/opt/homebrew/bin/bun" \
"/usr/local/bin/bun"; do
[[ -x "$c" ]] && { echo "$c"; return 0; }
done
command -v bun 2>/dev/null && return 0
return 1
}
BUN=$(find_bun) || { echo "ERROR: bun not found — install from https://bun.sh" >&2; exit 1; }
echo "Using bun: $BUN"
# ---------- locate qmd source ----------
# Prefer bundled source in tools/qmd/ (works in CI and dev),
# then fall back to globally installed qmd on dev machines.
QMD_SRC=""
for c in \
"$ROOT/tools/qmd" \
"$HOME/.bun/install/global/node_modules/qmd" \
"/opt/homebrew/lib/node_modules/qmd" \
"/usr/local/lib/node_modules/qmd"; do
[[ -f "$c/src/qmd.ts" ]] && { QMD_SRC="$c"; break; }
done
[[ -n "$QMD_SRC" ]] || { echo "ERROR: qmd source not found. tools/qmd/ is missing or incomplete." >&2; exit 1; }
echo "Using qmd source: $QMD_SRC"
# Install qmd dependencies if needed (for CI where node_modules don't exist yet)
if [[ ! -d "$QMD_SRC/node_modules" ]]; then
echo "Installing qmd dependencies..."
(cd "$QMD_SRC" && "$BUN" install --frozen-lockfile)
fi
# ---------- compile ----------
echo "Compiling qmd with bun build --compile..."
mkdir -p "$OUT"
(cd "$QMD_SRC" && "$BUN" build --compile \
"src/qmd.ts" \
--outfile "$OUT/qmd" \
--external node-llama-cpp \
--external sqlite-vec \
--external sqlite-vec-darwin-arm64 \
--external sqlite-vec-darwin-x64)
chmod +x "$OUT/qmd"
# ---------- bundle sqlite-vec ----------
echo "Bundling sqlite-vec native extensions..."
# Find sqlite-vec packages — prefer node_modules in QMD_SRC (after bun install),
# fall back to bun global cache for dev machines.
NM="$QMD_SRC/node_modules"
find_pkg() {
local pkg="$1"
# Check node_modules from bun install in QMD_SRC first
if [[ -d "$NM/$pkg" ]]; then
echo "$NM/$pkg"; return 0
fi
# Fall back to bun global cache
local cache_dir
cache_dir=$(find "$HOME/.bun/install/cache" -maxdepth 1 -name "${pkg}@*" -type d 2>/dev/null | head -1)
[[ -n "$cache_dir" ]] && echo "$cache_dir" && return 0
return 1
}
# sqlite-vec JS shim
SQLVEC_DIR=$(find_pkg "sqlite-vec") || { echo "ERROR: sqlite-vec not found" >&2; exit 1; }
mkdir -p "$OUT/node_modules/sqlite-vec"
cp "$SQLVEC_DIR/index.mjs" "$OUT/node_modules/sqlite-vec/index.mjs"
cp "$SQLVEC_DIR/package.json" "$OUT/node_modules/sqlite-vec/package.json"
[[ -f "$SQLVEC_DIR/index.cjs" ]] && cp "$SQLVEC_DIR/index.cjs" "$OUT/node_modules/sqlite-vec/index.cjs"
# sqlite-vec-darwin-arm64
ARM64_DIR=$(find_pkg "sqlite-vec-darwin-arm64") || true
if [[ -n "$ARM64_DIR" ]]; then
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-arm64"
cp "$ARM64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-arm64/vec0.dylib"
cp "$ARM64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-arm64/package.json"
echo " ✓ arm64 dylib"
fi
# sqlite-vec-darwin-x64
X64_DIR=$(find_pkg "sqlite-vec-darwin-x64") || true
if [[ -n "$X64_DIR" ]]; then
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-x64"
cp "$X64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-x64/vec0.dylib"
cp "$X64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-x64/package.json"
echo " ✓ x64 dylib"
fi
# ---------- stub node-llama-cpp ----------
echo "Creating node-llama-cpp stub (keyword search only)..."
mkdir -p "$OUT/node_modules/node-llama-cpp"
cat > "$OUT/node_modules/node-llama-cpp/package.json" << 'PJSON'
{"name":"node-llama-cpp","version":"0.0.0-stub","type":"module","main":"index.js"}
PJSON
cat > "$OUT/node_modules/node-llama-cpp/index.js" << 'STUB'
// Stub: node-llama-cpp not bundled — semantic search unavailable, keyword search works.
const unavailable = (name) => (...args) => {
throw new Error(`${name}() unavailable: node-llama-cpp not bundled. Keyword search still works.`);
};
export const getLlama = unavailable("getLlama");
export const resolveModelFile = unavailable("resolveModelFile");
export class LlamaChatSession {
constructor() { throw new Error("LlamaChatSession unavailable"); }
}
export const LlamaLogLevel = { Error: 0, Warn: 1, Info: 2, Debug: 3 };
STUB
# ---------- code signing (macOS) ----------
# In CI (APPLE_SIGNING_IDENTITY set): sign with Developer ID + hardened runtime (required for notarization)
# In dev (no identity): ad-hoc sign to remove quarantine
if [[ "$(uname)" == "Darwin" ]] && command -v codesign &>/dev/null; then
SIGN_ID="${APPLE_SIGNING_IDENTITY:--}"
if [[ "$SIGN_ID" != "-" ]]; then
echo "Signing bundled binaries with Developer ID: $SIGN_ID"
SIGN_OPTS=(--force --sign "$SIGN_ID" --options runtime --timestamp)
else
echo "Ad-hoc signing bundled binaries (dev mode)..."
SIGN_OPTS=(--force --sign -)
fi
codesign "${SIGN_OPTS[@]}" "$OUT/qmd" 2>/dev/null && echo " ✓ qmd signed" || echo " ⚠ qmd signing failed (non-fatal)"
while IFS= read -r -d '' dylib; do
codesign "${SIGN_OPTS[@]}" "$dylib" 2>/dev/null && echo "$(basename "$dylib") signed" || echo "$(basename "$dylib") signing failed (non-fatal)"
done < <(find "$OUT/node_modules" -name "*.dylib" -print0)
fi
# ---------- summary ----------
echo ""
echo "qmd bundled → $OUT/"
du -sh "$OUT/qmd"
du -sh "$OUT/node_modules"
echo "Done."

View File

@@ -2,3 +2,7 @@
# will have compiled files and executables
/target/
/gen/schemas
# Generated by build scripts
/resources/mcp-server/
/resources/qmd/

View File

@@ -1,14 +1,13 @@
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);
// Ensure resource directories exist for the Tauri build.
// These are gitignored and populated by scripts (bundle-qmd.sh, bundle-mcp-server.mjs).
// Without a placeholder, `tauri build` / `cargo test` fails if the scripts haven't run.
for dir in ["resources/qmd", "resources/mcp-server"] {
let path = std::path::Path::new(dir);
if !path.exists() {
std::fs::create_dir_all(path).ok();
std::fs::write(path.join(".placeholder"), "").ok();
}
}
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};
@@ -18,10 +19,21 @@ pub enum ClaudeStreamEvent {
Init { session_id: String },
/// Incremental text chunk.
TextDelta { text: String },
/// Incremental thinking/reasoning chunk.
ThinkingDelta { 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.
@@ -117,7 +129,7 @@ where
{
let bin = find_claude_binary()?;
let args = build_chat_args(&req);
run_claude_subprocess(&bin, &args, &mut emit)
run_claude_subprocess(&bin, &args, None, &mut emit)
}
/// Build CLI arguments for a chat stream request.
@@ -148,17 +160,18 @@ fn build_chat_args(req: &ChatStreamRequest) -> Vec<String> {
args
}
/// Spawn `claude -p` with MCP vault tools for an agent task and stream events.
/// Spawn `claude -p` with full tool access and MCP vault tools for an agent task.
pub fn run_agent_stream<F>(req: AgentStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(ClaudeStreamEvent),
{
let bin = find_claude_binary()?;
let args = build_agent_args(&req)?;
run_claude_subprocess(&bin, &args, &mut emit)
run_claude_subprocess(&bin, &args, Some(&req.vault_path), &mut emit)
}
/// Build CLI arguments for an agent stream request.
/// Native tools (bash, read, write, edit) are enabled by default — no `--tools ""`.
fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
let mcp_config = build_mcp_config(&req.vault_path)?;
@@ -169,8 +182,6 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
"stream-json".into(),
"--verbose".into(),
"--include-partial-messages".into(),
"--tools".into(),
String::new(), // disable built-in tools; MCP tools remain
"--mcp-config".into(),
mcp_config,
"--dangerously-skip-permissions".into(),
@@ -207,23 +218,46 @@ 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>
/// When `cwd` is `Some`, the subprocess starts with that working directory.
fn run_claude_subprocess<F>(
bin: &PathBuf,
args: &[String],
cwd: Option<&str>,
emit: &mut F,
) -> Result<String, String>
where
F: FnMut(ClaudeStreamEvent),
{
let mut child = Command::new(bin)
.args(args)
let mut cmd = Command::new(bin);
cmd.args(args)
.env_remove("CLAUDECODE") // prevent "nested session" guard
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
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 +279,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 +291,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 +307,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 +321,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 +330,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 +341,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 +361,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 +378,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 +395,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 +405,91 @@ 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("thinking_delta") => {
if let Some(text) = delta["thinking"].as_str() {
emit(ClaudeStreamEvent::ThinkingDelta {
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 +518,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 +539,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 +599,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 +632,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 +654,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 +672,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 +698,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)]
@@ -584,7 +822,7 @@ mod tests {
std::fs::write(&path, script).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut events = vec![];
let result = run_claude_subprocess(&path, args, &mut |e| events.push(e));
let result = run_claude_subprocess(&path, args, None, &mut |e| events.push(e));
(result, events)
}
@@ -735,6 +973,8 @@ mod tests {
assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
assert!(args.contains(&"--no-session-persistence".to_string()));
assert!(!args.contains(&"--append-system-prompt".to_string()));
// Native tools must NOT be disabled
assert!(!args.contains(&"--tools".to_string()));
}
}
@@ -806,7 +1046,7 @@ mod tests {
fn run_subprocess_spawn_failure() {
let fake_bin = PathBuf::from("/nonexistent/binary/path");
let mut events = vec![];
let result = run_claude_subprocess(&fake_bin, &[], &mut |e| events.push(e));
let result = run_claude_subprocess(&fake_bin, &[], None, &mut |e| events.push(e));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to spawn"));
}

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,103 @@ 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(())
}
/// Check whether a rebase is currently in progress.
pub fn is_rebase_in_progress(vault_path: &str) -> bool {
let vault = Path::new(vault_path);
let git_dir = vault.join(".git");
git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
}
/// Check whether a merge is currently in progress.
pub fn is_merge_in_progress(vault_path: &str) -> bool {
Path::new(vault_path)
.join(".git")
.join("MERGE_HEAD")
.exists()
}
/// Returns the current conflict mode: "rebase", "merge", or "none".
pub fn get_conflict_mode(vault_path: &str) -> String {
if is_rebase_in_progress(vault_path) {
"rebase".to_string()
} else if is_merge_in_progress(vault_path) {
"merge".to_string()
} else {
"none".to_string()
}
}
/// Commit after all conflicts have been resolved.
/// Detects whether the repo is in a merge or rebase state and uses the
/// appropriate command (`git commit` vs `git rebase --continue`).
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 mode = get_conflict_mode(vault_path);
let output = match mode.as_str() {
"rebase" => Command::new("git")
.args(["rebase", "--continue"])
.env("GIT_EDITOR", "true")
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
_ => Command::new("git")
.args(["commit", "-m", "Resolve merge conflicts"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?,
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
let cmd_name = if mode == "rebase" {
"git rebase --continue"
} else {
"git commit"
};
return Err(format!("{} failed: {}", cmd_name, detail.trim()));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
/// Push to remote.
pub fn git_push(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
@@ -482,6 +587,167 @@ pub fn git_push(vault_path: &str) -> Result<String, String> {
Ok(format!("{}{}", stdout, stderr))
}
#[derive(Debug, Serialize, Clone)]
pub struct PulseFile {
pub path: String,
pub status: String,
pub title: String,
}
#[derive(Debug, Serialize, Clone)]
pub struct PulseCommit {
pub hash: String,
#[serde(rename = "shortHash")]
pub short_hash: String,
pub message: String,
pub date: i64,
#[serde(rename = "githubUrl")]
pub github_url: Option<String>,
pub files: Vec<PulseFile>,
pub added: usize,
pub modified: usize,
pub deleted: usize,
}
fn title_from_path(path: &str) -> String {
path.rsplit('/')
.next()
.unwrap_or(path)
.strip_suffix(".md")
.unwrap_or(path)
.replace('-', " ")
}
fn parse_file_status(code: &str) -> &str {
match code {
"A" => "added",
"M" => "modified",
"D" => "deleted",
_ => "modified",
}
}
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result<Vec<PulseCommit>, String> {
let vault = Path::new(vault_path);
if !vault.join(".git").exists() {
return Err("Not a git repository".to_string());
}
let limit_str = limit.to_string();
let output = Command::new("git")
.args([
"log",
"--name-status",
"--pretty=format:%H|%h|%s|%aI",
"--diff-filter=ADM",
"-n",
&limit_str,
"--",
"*.md",
])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not have any commits yet") {
return Ok(Vec::new());
}
return Err(format!("git log failed: {}", stderr));
}
let github_base = get_github_base_url(vault_path);
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_pulse_output(&stdout, &github_base))
}
fn get_github_base_url(vault_path: &str) -> Option<String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(vault)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let repo_path = parse_github_repo_path(&url)?;
Some(format!("https://github.com/{}", repo_path))
}
fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCommit> {
let mut commits: Vec<PulseCommit> = Vec::new();
let mut current: Option<PulseCommit> = None;
for line in stdout.lines() {
if line.is_empty() {
continue;
}
if line.contains('|')
&& !line.starts_with(|c: char| {
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
})
{
// Commit header line: hash|short_hash|message|date
if let Some(commit) = current.take() {
commits.push(commit);
}
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() == 4 {
let hash = parts[0];
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
let github_url = github_base
.as_ref()
.map(|base| format!("{}/commit/{}", base, hash));
current = Some(PulseCommit {
hash: hash.to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
date,
github_url,
files: Vec::new(),
added: 0,
modified: 0,
deleted: 0,
});
}
} else if let Some(ref mut commit) = current {
// File status line: A\tpath or M\tpath
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
if file_parts.len() == 2 {
let status = parse_file_status(file_parts[0].trim());
let path = file_parts[1].trim();
match status {
"added" => commit.added += 1,
"deleted" => commit.deleted += 1,
_ => commit.modified += 1,
}
commit.files.push(PulseFile {
path: path.to_string(),
status: status.to_string(),
title: title_from_path(path),
});
}
}
}
if let Some(commit) = current {
commits.push(commit);
}
commits
}
#[derive(Debug, Serialize, Clone)]
pub struct LastCommitInfo {
#[serde(rename = "shortHash")]
@@ -1217,6 +1483,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!(
@@ -1224,4 +1597,311 @@ mod tests {
None
);
}
#[test]
fn test_conflict_mode_none_for_clean_repo() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
assert_eq!(get_conflict_mode(vp), "none");
assert!(!is_rebase_in_progress(vp));
assert!(!is_merge_in_progress(vp));
}
#[test]
fn test_conflict_mode_merge_during_merge_conflict() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
// setup_conflict_pair creates a merge conflict via git_pull (--no-rebase)
assert_eq!(get_conflict_mode(vp_b), "merge");
assert!(is_merge_in_progress(vp_b));
assert!(!is_rebase_in_progress(vp_b));
}
#[test]
fn test_commit_conflict_resolution_merge_mode() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "merge");
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
// After resolution, mode should be none
assert_eq!(get_conflict_mode(vp_b), "none");
}
/// Set up a rebase conflict: clone_b has diverged from origin and
/// `git pull --rebase` causes a conflict.
fn setup_rebase_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 with --rebase to cause a rebase conflict
let output = Command::new("git")
.args(["pull", "--rebase"])
.current_dir(clone_b_dir.path())
.output()
.unwrap();
// Pull should fail due to conflict
assert!(
!output.status.success(),
"Expected rebase conflict, but pull succeeded"
);
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_conflict_mode_rebase_during_rebase_conflict() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "rebase");
assert!(is_rebase_in_progress(vp_b));
assert!(!is_merge_in_progress(vp_b));
}
#[test]
fn test_get_conflict_files_during_rebase() {
let (_bare, _clone_a, clone_b) = setup_rebase_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()),
"Should detect conflict.md during rebase, got: {:?}",
conflicts
);
}
#[test]
fn test_resolve_and_continue_rebase() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "rebase");
// Resolve the conflict
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
// Commit resolution should use rebase --continue
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok(), "rebase --continue failed: {:?}", result);
// After resolution, mode should be none
assert_eq!(get_conflict_mode(vp_b), "none");
}
#[test]
fn test_get_vault_pulse_with_commits() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
fs::write(vault.join("project.md"), "# Project\n").unwrap();
git_commit(vp, "Add project").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert_eq!(pulse.len(), 2);
assert_eq!(pulse[0].message, "Add project");
assert_eq!(pulse[1].message, "Add note");
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "project.md");
assert_eq!(pulse[0].files[0].status, "added");
assert_eq!(pulse[0].added, 1);
assert_eq!(pulse[0].modified, 0);
assert!(!pulse[0].short_hash.is_empty());
}
#[test]
fn test_get_vault_pulse_no_git() {
let dir = TempDir::new().unwrap();
let vp = dir.path().to_str().unwrap();
let result = get_vault_pulse(vp, 30);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Not a git repository"));
}
#[test]
fn test_get_vault_pulse_empty_repo() {
let dir = setup_git_repo();
let vp = dir.path().to_str().unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert!(pulse.is_empty());
}
#[test]
fn test_get_vault_pulse_only_md_files() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
fs::write(vault.join("config.json"), "{}").unwrap();
git_commit(vp, "Add files").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert_eq!(pulse.len(), 1);
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "note.md");
}
#[test]
fn test_get_vault_pulse_respects_limit() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
for i in 0..5 {
fs::write(
vault.join(format!("note{}.md", i)),
format!("# Note {}\n", i),
)
.unwrap();
git_commit(vp, &format!("Add note {}", i)).unwrap();
}
let pulse = get_vault_pulse(vp, 3).unwrap();
assert_eq!(pulse.len(), 3);
}
#[test]
fn test_get_vault_pulse_modified_and_deleted() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
git_commit(vp, "Update note").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert_eq!(pulse[0].message, "Update note");
assert_eq!(pulse[0].files[0].status, "modified");
assert_eq!(pulse[0].modified, 1);
}
#[test]
fn test_get_vault_pulse_github_url() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/owner/repo.git",
])
.current_dir(vault)
.output()
.unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert!(pulse[0].github_url.is_some());
let url = pulse[0].github_url.as_ref().unwrap();
assert!(url.starts_with("https://github.com/owner/repo/commit/"));
}
#[test]
fn test_get_vault_pulse_no_github_url_without_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
assert!(pulse[0].github_url.is_none());
}
#[test]
fn test_title_from_path() {
assert_eq!(title_from_path("note/my-project.md"), "my project");
assert_eq!(title_from_path("simple.md"), "simple");
assert_eq!(title_from_path("deep/nested/file.md"), "file");
}
#[test]
fn test_parse_pulse_output_basic() {
let stdout =
"abc123|abc123d|Add notes|2026-03-05T10:00:00+01:00\nA\tnote.md\nM\tproject.md\n";
let commits = parse_pulse_output(stdout, &None);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].message, "Add notes");
assert_eq!(commits[0].files.len(), 2);
assert_eq!(commits[0].files[0].status, "added");
assert_eq!(commits[0].files[1].status, "modified");
assert_eq!(commits[0].added, 1);
assert_eq!(commits[0].modified, 1);
assert!(commits[0].github_url.is_none());
}
#[test]
fn test_parse_pulse_output_with_github() {
let stdout = "abc123|abc123d|Msg|2026-03-05T10:00:00+01:00\nA\tnote.md\n";
let base = Some("https://github.com/o/r".to_string());
let commits = parse_pulse_output(stdout, &base);
assert_eq!(
commits[0].github_url.as_deref(),
Some("https://github.com/o/r/commit/abc123")
);
}
#[test]
fn test_parse_pulse_output_multiple_commits() {
let stdout = "aaa|aaa1234|First|2026-03-05T10:00:00+01:00\nA\ta.md\n\nbbb|bbb1234|Second|2026-03-04T10:00:00+01:00\nM\tb.md\nD\tc.md\n";
let commits = parse_pulse_output(stdout, &None);
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].message, "First");
assert_eq!(commits[1].message, "Second");
assert_eq!(commits[1].files.len(), 2);
assert_eq!(commits[1].deleted, 1);
}
}

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

@@ -0,0 +1,701 @@
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
/// Resolved qmd binary location: path + optional working directory.
/// The working dir is required for the bundled binary to find its node_modules.
#[derive(Debug, Clone)]
pub struct QmdBinary {
pub path: String,
pub work_dir: Option<PathBuf>,
}
impl QmdBinary {
/// Create a `Command` pre-configured with the correct working directory.
pub fn command(&self) -> Command {
let mut cmd = Command::new(&self.path);
if let Some(ref dir) = self.work_dir {
cmd.current_dir(dir);
}
cmd
}
}
static QMD_CACHE: Mutex<Option<QmdBinary>> = Mutex::new(None);
/// Locate the qmd binary, checking bundled resource first, then known locations.
/// Caches the result for subsequent calls.
pub fn find_qmd_binary() -> Option<QmdBinary> {
if let Ok(guard) = QMD_CACHE.lock() {
if let Some(ref cached) = *guard {
return Some(cached.clone());
}
}
let result = find_qmd_binary_uncached();
if let Some(ref bin) = result {
if let Ok(mut guard) = QMD_CACHE.lock() {
*guard = Some(bin.clone());
}
}
result
}
fn find_qmd_binary_uncached() -> Option<QmdBinary> {
// 1. Check bundled binary (Tauri resource)
if let Some(bin) = find_bundled_qmd() {
return Some(bin);
}
// 2. Check known system locations
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(QmdBinary {
path: candidate,
work_dir: None,
});
}
}
// 3. Fallback: try PATH
Command::new("which")
.arg("qmd")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(QmdBinary {
path: String::from_utf8_lossy(&o.stdout).trim().to_string(),
work_dir: None,
})
} else {
None
}
})
}
/// Look for the bundled qmd binary inside the app bundle or dev resources.
fn find_bundled_qmd() -> Option<QmdBinary> {
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
// macOS app bundle: <app>/Contents/MacOS/laputa → <app>/Contents/Resources/qmd/qmd
let bundle_dir = exe_dir.parent()?.join("Resources").join("qmd");
if let Some(bin) = prepare_bundled_dir(&bundle_dir) {
return Some(bin);
}
// Dev mode: use compile-time CARGO_MANIFEST_DIR for reliable path resolution
let dev_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("qmd");
if let Some(bin) = prepare_bundled_dir(&dev_dir) {
return Some(bin);
}
// Dev mode fallback: walk up from exe_dir to find the project root
let mut dir = exe_dir.to_path_buf();
for _ in 0..6 {
let qmd_dir = dir.join("resources").join("qmd");
if let Some(bin) = prepare_bundled_dir(&qmd_dir) {
return Some(bin);
}
if !dir.pop() {
break;
}
}
None
}
/// Validate a bundled qmd directory and prepare the binary for execution.
/// Sets execute permissions and removes macOS quarantine attributes.
fn prepare_bundled_dir(qmd_dir: &Path) -> Option<QmdBinary> {
let qmd_path = qmd_dir.join("qmd");
if !qmd_path.exists() {
return None;
}
ensure_executable(&qmd_path);
// Remove macOS quarantine attributes that block execution of bundled binaries
#[cfg(target_os = "macos")]
{
let _ = Command::new("xattr")
.args(["-rd", "com.apple.quarantine"])
.arg(qmd_dir)
.output();
}
Some(QmdBinary {
path: qmd_path.to_string_lossy().to_string(),
work_dir: Some(qmd_dir.to_path_buf()),
})
}
/// Ensure a file has execute permission.
#[cfg(unix)]
fn ensure_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = path.metadata() {
let mode = metadata.permissions().mode();
if mode & 0o111 == 0 {
let mut perms = metadata.permissions();
perms.set_mode(mode | 0o755);
let _ = std::fs::set_permissions(path, perms);
}
}
}
#[cfg(not(unix))]
fn ensure_executable(_path: &Path) {}
/// Try to install qmd globally using bun. Returns Ok if installation succeeded.
pub fn try_auto_install_qmd() -> Result<(), String> {
let bun = find_bun().ok_or("bun not found — cannot auto-install qmd")?;
log::info!("Auto-installing qmd via bun...");
let output = Command::new(&bun)
.args(["install", "-g", "qmd"])
.output()
.map_err(|e| format!("Failed to run bun install: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("bun install -g qmd failed: {stderr}"));
}
// Clear cache so the newly installed binary is discovered
clear_qmd_cache();
log::info!("qmd auto-install succeeded");
Ok(())
}
/// Locate bun binary for auto-installing qmd.
fn find_bun() -> Option<PathBuf> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/bun")),
Some(PathBuf::from("/opt/homebrew/bin/bun")),
Some(PathBuf::from("/usr/local/bin/bun")),
];
for candidate in candidates.into_iter().flatten() {
if candidate.exists() {
return Some(candidate);
}
}
// Fallback: try PATH
Command::new("which")
.arg("bun")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(PathBuf::from(
String::from_utf8_lossy(&o.stdout).trim().to_string(),
))
} else {
None
}
})
}
/// Clear the cached qmd binary (e.g. after path changes or installation).
pub fn clear_qmd_cache() {
if let Ok(mut guard) = QMD_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 = 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 = qmd.command().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 = find_qmd_binary().ok_or("qmd not installed")?;
let vault_name = vault_dir_name(vault_path);
// Check if collection already exists
let output = qmd
.command()
.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
qmd.command()
.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 = 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 = qmd
.command()
.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 = qmd
.command()
.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 = find_qmd_binary().ok_or("qmd not installed")?;
// Verify collection exists
let vault_name = vault_dir_name(vault_path);
let list_output = qmd
.command()
.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 = qmd
.command()
.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(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qmd_binary_command_sets_work_dir() {
let qmd = QmdBinary {
path: "/bin/echo".to_string(),
work_dir: Some(PathBuf::from("/tmp")),
};
let cmd = qmd.command();
let dbg = format!("{:?}", cmd);
assert!(dbg.contains("/bin/echo"));
}
#[test]
fn qmd_binary_command_no_work_dir() {
let qmd = QmdBinary {
path: "/bin/echo".to_string(),
work_dir: None,
};
let output = qmd.command().arg("hello").output().unwrap();
assert!(output.status.success());
}
#[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);
}
#[test]
fn ensure_executable_sets_permission() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test-bin");
fs::write(&file, "#!/bin/sh\necho ok").unwrap();
// Start with no execute permission
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0);
ensure_executable(&file);
assert_ne!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0);
}
}
#[test]
fn ensure_executable_noop_when_already_executable() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test-bin");
fs::write(&file, "#!/bin/sh\necho ok").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap();
ensure_executable(&file);
let mode = fs::metadata(&file).unwrap().permissions().mode();
assert_ne!(mode & 0o111, 0);
}
}
#[test]
fn prepare_bundled_dir_returns_none_for_missing_binary() {
let dir = tempfile::tempdir().unwrap();
assert!(prepare_bundled_dir(dir.path()).is_none());
}
#[test]
fn prepare_bundled_dir_finds_and_prepares_binary() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let qmd_path = dir.path().join("qmd");
fs::write(&qmd_path, "#!/bin/sh\necho ok").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&qmd_path, fs::Permissions::from_mode(0o644)).unwrap();
}
let result = prepare_bundled_dir(dir.path());
assert!(result.is_some());
let bin = result.unwrap();
assert!(bin.path.ends_with("qmd"));
assert_eq!(bin.work_dir.unwrap(), dir.path());
// Verify execute permission was set
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_ne!(
fs::metadata(&qmd_path).unwrap().permissions().mode() & 0o111,
0
);
}
}
#[test]
fn find_bun_returns_some_if_available() {
// This test may succeed or fail depending on the system.
// It verifies the function doesn't panic.
let _ = find_bun();
}
}

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_config;
pub mod vault_list;
use std::borrow::Cow;
@@ -19,12 +21,14 @@ use std::sync::Mutex;
use ai_chat::{AiChatRequest, AiChatResponse};
use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent};
use frontmatter::FrontmatterValue;
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile};
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
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_config::VaultConfig;
use vault_list::VaultList;
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
@@ -108,15 +112,32 @@ fn get_file_diff_at_commit(
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
#[tauri::command]
fn get_vault_pulse(vault_path: String, limit: Option<usize>) -> Result<Vec<PulseCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let limit = limit.unwrap_or(30);
git::get_vault_pulse(&vault_path, limit)
}
#[tauri::command]
fn git_commit(vault_path: String, message: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit(&vault_path, &message)
}
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() -> String {
format!("b{}", env!("BUILD_NUMBER"))
fn get_build_number(app_handle: tauri::AppHandle) -> String {
let version = app_handle.package_info().version.to_string();
parse_build_label(&version)
}
#[tauri::command]
@@ -131,6 +152,30 @@ fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
git::git_pull(&vault_path)
}
#[tauri::command]
fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_files(&vault_path)
}
#[tauri::command]
fn get_conflict_mode(vault_path: String) -> String {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_mode(&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);
@@ -267,8 +312,19 @@ fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
}
#[tauri::command]
fn update_menu_state(app_handle: tauri::AppHandle, has_active_note: bool) -> Result<(), String> {
fn update_menu_state(
app_handle: tauri::AppHandle,
has_active_note: bool,
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, has_active_note);
if let Some(v) = has_modified_files {
menu::set_git_commit_items_enabled(&app_handle, v);
}
if let Some(v) = has_conflicts {
menu::set_git_conflict_items_enabled(&app_handle, v);
}
Ok(())
}
@@ -341,6 +397,77 @@ 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)
}
fn emit_unavailable(app_handle: &tauri::AppHandle) {
use tauri::Emitter;
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "unavailable".to_string(),
current: 0,
total: 0,
done: true,
error: Some("qmd not available".to_string()),
},
);
}
#[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 || {
if indexing::find_qmd_binary().is_none() {
log::info!("qmd binary not found — attempting auto-install via bun");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "installing".to_string(),
current: 0,
total: 0,
done: false,
error: None,
},
);
match indexing::try_auto_install_qmd() {
Ok(()) if indexing::find_qmd_binary().is_some() => {
log::info!("qmd auto-installed successfully, proceeding with indexing");
}
Ok(()) => {
log::warn!("qmd auto-install reported success but binary still not found");
emit_unavailable(&app_handle);
return Err("qmd not available after install".to_string());
}
Err(e) => {
log::info!("qmd auto-install failed: {e}");
emit_unavailable(&app_handle);
return Err(format!("qmd not available: {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]
@@ -351,6 +478,13 @@ async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
.map_err(|e| format!("Registration task failed: {e}"))?
}
#[tauri::command]
async fn check_mcp_status() -> Result<mcp::McpStatus, String> {
tokio::task::spawn_blocking(mcp::check_mcp_status)
.await
.map_err(|e| format!("MCP status check failed: {e}"))
}
#[tauri::command]
fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
let vault_path = expand_tilde(&vault_path);
@@ -376,9 +510,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]
@@ -393,6 +527,30 @@ fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String
theme::create_vault_theme(&vault_path, name.as_deref())
}
#[tauri::command]
fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::ensure_vault_themes(&vault_path)
}
#[tauri::command]
fn restore_default_themes(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::restore_default_themes(&vault_path)
}
#[tauri::command]
fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
let vault_path = expand_tilde(&vault_path);
vault_config::get_vault_config(&vault_path)
}
#[tauri::command]
fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
vault_config::save_vault_config(&vault_path, config)
}
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
@@ -468,14 +626,21 @@ 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
);
assert_ne!(result, "b0", "build number should not fall back to 0");
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?");
}
}
@@ -538,11 +703,16 @@ pub fn run() {
get_modified_files,
get_file_diff,
get_file_diff_at_commit,
get_vault_pulse,
git_commit,
get_build_number,
get_last_commit_info,
git_pull,
git_push,
get_conflict_files,
get_conflict_mode,
git_resolve_conflict,
git_commit_conflict_resolution,
ai_chat,
check_claude_cli,
stream_claude_chat,
@@ -566,17 +736,25 @@ 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,
register_mcp_tools,
check_mcp_status,
list_themes,
get_theme,
get_vault_settings,
save_vault_settings,
set_active_theme,
create_theme,
create_vault_theme
create_vault_theme,
ensure_vault_themes,
restore_default_themes,
get_vault_config,
save_vault_config
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View File

@@ -1,6 +1,19 @@
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
/// Status of the MCP server installation.
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum McpStatus {
/// MCP is registered in Claude config and server files exist.
Installed,
/// MCP server files or config are missing but can be installed.
NotInstalled,
/// Claude CLI is not installed — must install that first.
NoClaudeCli,
}
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let output = Command::new("which")
@@ -144,6 +157,57 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
Ok(was_update)
}
/// Check whether the MCP server is properly installed and registered.
///
/// Returns `Installed` when the laputa entry exists in `~/.claude/mcp.json`
/// and the referenced index.js file is present. Returns `NoClaudeCli` when
/// the Claude CLI binary cannot be found. Otherwise returns `NotInstalled`.
pub fn check_mcp_status() -> McpStatus {
// Check Claude CLI first — no point installing MCP if Claude isn't available
if crate::claude_cli::find_claude_binary().is_err() {
return McpStatus::NoClaudeCli;
}
let config_path = match dirs::home_dir() {
Some(h) => h.join(".claude").join("mcp.json"),
None => return McpStatus::NotInstalled,
};
if !config_path.exists() {
return McpStatus::NotInstalled;
}
let raw = match std::fs::read_to_string(&config_path) {
Ok(r) => r,
Err(_) => return McpStatus::NotInstalled,
};
let config: serde_json::Value = match serde_json::from_str(&raw) {
Ok(c) => c,
Err(_) => return McpStatus::NotInstalled,
};
let entry = &config["mcpServers"]["laputa"];
if entry.is_null() {
return McpStatus::NotInstalled;
}
// Verify the referenced index.js actually exists on disk
if let Some(index_js) = entry["args"]
.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
{
if !Path::new(index_js).exists() {
return McpStatus::NotInstalled;
}
} else {
return McpStatus::NotInstalled;
}
McpStatus::Installed
}
#[cfg(test)]
mod tests {
use super::*;
@@ -314,4 +378,29 @@ mod tests {
// With empty config list, there were no updates, so status should be "registered"
assert_eq!(status, "registered");
}
#[test]
fn check_mcp_status_returns_valid_variant() {
// On a dev machine with Claude CLI and MCP registered, this should be Installed.
// On CI without Claude it might be NoClaudeCli. Either way it must not panic.
let status = check_mcp_status();
assert!(
matches!(
status,
McpStatus::Installed | McpStatus::NotInstalled | McpStatus::NoClaudeCli
),
"unexpected status: {:?}",
status
);
}
#[test]
fn mcp_status_serializes_to_snake_case() {
let json = serde_json::to_string(&McpStatus::Installed).unwrap();
assert_eq!(json, r#""installed""#);
let json = serde_json::to_string(&McpStatus::NotInstalled).unwrap();
assert_eq!(json, r#""not_installed""#);
let json = serde_json::to_string(&McpStatus::NoClaudeCli).unwrap();
assert_eq!(json, r#""no_claude_cli""#);
}
}

View File

@@ -5,49 +5,109 @@ use tauri::{
// Custom menu item IDs that emit events to the frontend.
const APP_SETTINGS: &str = "app-settings";
const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
const FILE_NEW_NOTE: &str = "file-new-note";
const FILE_NEW_TYPE: &str = "file-new-type";
const FILE_DAILY_NOTE: &str = "file-daily-note";
const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_SAVE: &str = "file-save";
const FILE_CLOSE_TAB: &str = "file-close-tab";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff";
const VIEW_EDITOR_ONLY: &str = "view-editor-only";
const VIEW_EDITOR_LIST: &str = "view-editor-list";
const VIEW_ALL: &str = "view-all";
const VIEW_TOGGLE_INSPECTOR: &str = "view-toggle-inspector";
const VIEW_TOGGLE_PROPERTIES: &str = "view-toggle-properties";
const VIEW_TOGGLE_AI_CHAT: &str = "view-toggle-ai-chat";
const VIEW_TOGGLE_BACKLINKS: &str = "view-toggle-backlinks";
const VIEW_COMMAND_PALETTE: &str = "view-command-palette";
const VIEW_ZOOM_IN: &str = "view-zoom-in";
const VIEW_ZOOM_OUT: &str = "view-zoom-out";
const VIEW_ZOOM_RESET: &str = "view-zoom-reset";
const NOTE_ARCHIVE: &str = "note-archive";
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 GO_ALL_NOTES: &str = "go-all-notes";
const GO_FAVORITES: &str = "go-favorites";
const GO_ARCHIVED: &str = "go-archived";
const GO_TRASH: &str = "go-trash";
const GO_CHANGES: &str = "go-changes";
const NOTE_ARCHIVE: &str = "note-archive";
const NOTE_TRASH: &str = "note-trash";
const VAULT_OPEN: &str = "vault-open";
const VAULT_REMOVE: &str = "vault-remove";
const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started";
const VAULT_NEW_THEME: &str = "vault-new-theme";
const VAULT_RESTORE_DEFAULT_THEMES: &str = "vault-restore-default-themes";
const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
APP_CHECK_FOR_UPDATES,
FILE_NEW_NOTE,
FILE_NEW_TYPE,
FILE_DAILY_NOTE,
FILE_QUICK_OPEN,
FILE_SAVE,
FILE_CLOSE_TAB,
NOTE_ARCHIVE,
NOTE_TRASH,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_EDITOR_ONLY,
VIEW_EDITOR_LIST,
VIEW_ALL,
VIEW_TOGGLE_INSPECTOR,
VIEW_TOGGLE_PROPERTIES,
VIEW_TOGGLE_AI_CHAT,
VIEW_TOGGLE_BACKLINKS,
VIEW_COMMAND_PALETTE,
VIEW_ZOOM_IN,
VIEW_ZOOM_OUT,
VIEW_ZOOM_RESET,
VIEW_GO_BACK,
VIEW_GO_FORWARD,
GO_ALL_NOTES,
GO_FAVORITES,
GO_ARCHIVED,
GO_TRASH,
GO_CHANGES,
NOTE_ARCHIVE,
NOTE_TRASH,
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
VAULT_NEW_THEME,
VAULT_RESTORE_DEFAULT_THEMES,
VAULT_COMMIT_PUSH,
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
];
/// IDs of menu items that should be disabled when no note tab is active.
const NOTE_DEPENDENT_IDS: &[&str] = &[FILE_SAVE, FILE_CLOSE_TAB, NOTE_ARCHIVE, NOTE_TRASH];
const NOTE_DEPENDENT_IDS: &[&str] = &[
FILE_SAVE,
FILE_CLOSE_TAB,
NOTE_ARCHIVE,
NOTE_TRASH,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_TOGGLE_BACKLINKS,
];
/// IDs of menu items that depend on having uncommitted changes.
const GIT_COMMIT_DEPENDENT_IDS: &[&str] = &[VAULT_COMMIT_PUSH];
/// IDs of menu items that depend on having merge conflicts.
const GIT_CONFLICT_DEPENDENT_IDS: &[&str] = &[VAULT_RESOLVE_CONFLICTS];
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
@@ -56,10 +116,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()
@@ -77,6 +142,9 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_NEW_NOTE)
.accelerator("CmdOrCtrl+N")
.build(app)?;
let new_type = MenuItemBuilder::new("New Type")
.id(FILE_NEW_TYPE)
.build(app)?;
let daily_note = MenuItemBuilder::new("Open Today's Note")
.id(FILE_DAILY_NOTE)
.accelerator("CmdOrCtrl+J")
@@ -93,25 +161,14 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_CLOSE_TAB)
.accelerator("CmdOrCtrl+W")
.build(app)?;
let archive_note = MenuItemBuilder::new("Archive Note")
.id(NOTE_ARCHIVE)
.accelerator("CmdOrCtrl+E")
.build(app)?;
let trash_note = MenuItemBuilder::new("Trash Note")
.id(NOTE_TRASH)
.accelerator("CmdOrCtrl+Backspace")
.build(app)?;
Ok(SubmenuBuilder::new(app, "File")
.item(&new_note)
.item(&new_type)
.item(&daily_note)
.item(&quick_open)
.separator()
.item(&save)
.separator()
.item(&archive_note)
.item(&trash_note)
.separator()
.item(&close_tab)
.build()?)
}
@@ -121,6 +178,9 @@ fn build_edit_menu(app: &App) -> MenuResult {
.id(EDIT_FIND_IN_VAULT)
.accelerator("CmdOrCtrl+Shift+F")
.build(app)?;
let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode")
.id(EDIT_TOGGLE_DIFF)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Edit")
.undo()
@@ -133,6 +193,7 @@ fn build_edit_menu(app: &App) -> MenuResult {
.select_all()
.separator()
.item(&find_in_vault)
.item(&toggle_diff)
.build()?)
}
@@ -149,8 +210,8 @@ fn build_view_menu(app: &App) -> MenuResult {
.id(VIEW_ALL)
.accelerator("CmdOrCtrl+3")
.build(app)?;
let toggle_inspector = MenuItemBuilder::new("Toggle Inspector")
.id(VIEW_TOGGLE_INSPECTOR)
let toggle_properties = MenuItemBuilder::new("Toggle Properties Panel")
.id(VIEW_TOGGLE_PROPERTIES)
.build(app)?;
let command_palette = MenuItemBuilder::new("Command Palette")
.id(VIEW_COMMAND_PALETTE)
@@ -168,6 +229,34 @@ fn build_view_menu(app: &App) -> MenuResult {
.id(VIEW_ZOOM_RESET)
.accelerator("CmdOrCtrl+0")
.build(app)?;
Ok(SubmenuBuilder::new(app, "View")
.item(&editor_only)
.item(&editor_list)
.item(&all_panels)
.separator()
.item(&toggle_properties)
.separator()
.item(&zoom_in)
.item(&zoom_out)
.item(&zoom_reset)
.separator()
.item(&command_palette)
.build()?)
}
fn build_go_menu(app: &App) -> MenuResult {
let all_notes = MenuItemBuilder::new("All Notes")
.id(GO_ALL_NOTES)
.build(app)?;
let favorites = MenuItemBuilder::new("Favorites")
.id(GO_FAVORITES)
.build(app)?;
let archived = MenuItemBuilder::new("Archived")
.id(GO_ARCHIVED)
.build(app)?;
let trash = MenuItemBuilder::new("Trash").id(GO_TRASH).build(app)?;
let changes = MenuItemBuilder::new("Changes").id(GO_CHANGES).build(app)?;
let go_back = MenuItemBuilder::new("Go Back")
.id(VIEW_GO_BACK)
.accelerator("CmdOrCtrl+[")
@@ -177,21 +266,92 @@ fn build_view_menu(app: &App) -> MenuResult {
.accelerator("CmdOrCtrl+]")
.build(app)?;
Ok(SubmenuBuilder::new(app, "View")
.item(&editor_only)
.item(&editor_list)
.item(&all_panels)
.separator()
.item(&toggle_inspector)
Ok(SubmenuBuilder::new(app, "Go")
.item(&all_notes)
.item(&favorites)
.item(&archived)
.item(&trash)
.item(&changes)
.separator()
.item(&go_back)
.item(&go_forward)
.build()?)
}
fn build_note_menu(app: &App) -> MenuResult {
let archive_note = MenuItemBuilder::new("Archive Note")
.id(NOTE_ARCHIVE)
.accelerator("CmdOrCtrl+E")
.build(app)?;
let trash_note = MenuItemBuilder::new("Trash Note")
.id(NOTE_TRASH)
.accelerator("CmdOrCtrl+Backspace")
.build(app)?;
let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor")
.id(EDIT_TOGGLE_RAW_EDITOR)
.accelerator("CmdOrCtrl+\\")
.build(app)?;
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Chat")
.id(VIEW_TOGGLE_AI_CHAT)
.accelerator("CmdOrCtrl+I")
.build(app)?;
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
.id(VIEW_TOGGLE_BACKLINKS)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Note")
.item(&archive_note)
.item(&trash_note)
.separator()
.item(&zoom_in)
.item(&zoom_out)
.item(&zoom_reset)
.item(&toggle_raw_editor)
.item(&toggle_ai_chat)
.item(&toggle_backlinks)
.build()?)
}
fn build_vault_menu(app: &App) -> MenuResult {
let open_vault = MenuItemBuilder::new("Open Vault…")
.id(VAULT_OPEN)
.build(app)?;
let remove_vault = MenuItemBuilder::new("Remove Vault from List")
.id(VAULT_REMOVE)
.build(app)?;
let restore_getting_started = MenuItemBuilder::new("Restore Getting Started")
.id(VAULT_RESTORE_GETTING_STARTED)
.build(app)?;
let new_theme = MenuItemBuilder::new("New Theme")
.id(VAULT_NEW_THEME)
.build(app)?;
let restore_default_themes = MenuItemBuilder::new("Restore Default Themes")
.id(VAULT_RESTORE_DEFAULT_THEMES)
.build(app)?;
let commit_push = MenuItemBuilder::new("Commit & Push")
.id(VAULT_COMMIT_PUSH)
.build(app)?;
let resolve_conflicts = MenuItemBuilder::new("Resolve Conflicts")
.id(VAULT_RESOLVE_CONFLICTS)
.enabled(false)
.build(app)?;
let view_changes = MenuItemBuilder::new("View Pending Changes")
.id(VAULT_VIEW_CHANGES)
.build(app)?;
let install_mcp = MenuItemBuilder::new("Install MCP Server")
.id(VAULT_INSTALL_MCP)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Vault")
.item(&open_vault)
.item(&remove_vault)
.item(&restore_getting_started)
.separator()
.item(&command_palette)
.item(&new_theme)
.item(&restore_default_themes)
.separator()
.item(&commit_push)
.item(&resolve_conflicts)
.item(&view_changes)
.separator()
.item(&install_mcp)
.build()?)
}
@@ -209,6 +369,9 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
let file_menu = build_file_menu(app)?;
let edit_menu = build_edit_menu(app)?;
let view_menu = build_view_menu(app)?;
let go_menu = build_go_menu(app)?;
let note_menu = build_note_menu(app)?;
let vault_menu = build_vault_menu(app)?;
let window_menu = build_window_menu(app)?;
let menu = MenuBuilder::new(app)
@@ -216,6 +379,9 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
.item(&file_menu)
.item(&edit_menu)
.item(&view_menu)
.item(&go_menu)
.item(&note_menu)
.item(&vault_menu)
.item(&window_menu)
.build()?;
@@ -231,44 +397,78 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
/// Enable or disable menu items that depend on having an active note tab.
pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
fn set_items_enabled(app_handle: &AppHandle, ids: &[&str], enabled: bool) {
let Some(menu) = app_handle.menu() else {
return;
};
for id in NOTE_DEPENDENT_IDS {
for id in ids {
if let Some(MenuItemKind::MenuItem(mi)) = menu.get(*id) {
let _ = mi.set_enabled(enabled);
}
}
}
/// Enable or disable menu items that depend on having an active note tab.
pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on having uncommitted changes.
pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_COMMIT_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on having merge conflicts.
pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_CONFLICT_DEPENDENT_IDS, enabled);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_ids_include_all_expected_items() {
fn custom_ids_include_all_constants() {
let expected = [
"app-settings",
"file-new-note",
"file-daily-note",
"file-quick-open",
"file-save",
"file-close-tab",
"note-archive",
"note-trash",
"edit-find-in-vault",
"view-editor-only",
"view-editor-list",
"view-all",
"view-toggle-inspector",
"view-command-palette",
"view-zoom-in",
"view-zoom-out",
"view-zoom-reset",
"view-go-back",
"view-go-forward",
APP_SETTINGS,
APP_CHECK_FOR_UPDATES,
FILE_NEW_NOTE,
FILE_NEW_TYPE,
FILE_DAILY_NOTE,
FILE_QUICK_OPEN,
FILE_SAVE,
FILE_CLOSE_TAB,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_EDITOR_ONLY,
VIEW_EDITOR_LIST,
VIEW_ALL,
VIEW_TOGGLE_PROPERTIES,
VIEW_TOGGLE_AI_CHAT,
VIEW_TOGGLE_BACKLINKS,
VIEW_COMMAND_PALETTE,
VIEW_ZOOM_IN,
VIEW_ZOOM_OUT,
VIEW_ZOOM_RESET,
VIEW_GO_BACK,
VIEW_GO_FORWARD,
GO_ALL_NOTES,
GO_FAVORITES,
GO_ARCHIVED,
GO_TRASH,
GO_CHANGES,
NOTE_ARCHIVE,
NOTE_TRASH,
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
VAULT_NEW_THEME,
VAULT_RESTORE_DEFAULT_THEMES,
VAULT_COMMIT_PUSH,
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
@@ -284,4 +484,28 @@ mod tests {
);
}
}
#[test]
fn git_dependent_ids_are_subset_of_custom_ids() {
for id in GIT_COMMIT_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"git-commit-dependent ID {id} not in CUSTOM_IDS"
);
}
for id in GIT_CONFLICT_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"git-conflict-dependent ID {id} not in CUSTOM_IDS"
);
}
}
#[test]
fn no_duplicate_custom_ids() {
let mut seen = std::collections::HashSet::new();
for id in CUSTOM_IDS {
assert!(seen.insert(id), "duplicate custom ID: {id}");
}
}
}

View File

@@ -1,7 +1,7 @@
use crate::indexing;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::sync::Mutex;
use std::time::Instant;
@@ -30,31 +30,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,12 +83,12 @@ 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 = match indexing::find_qmd_binary() {
Some(b) => b,
None => return "laputa".to_string(),
};
let output = Command::new(&qmd_bin).args(["collection", "list"]).output();
let output = qmd.command().args(["collection", "list"]).output();
match output {
Ok(o) if o.status.success() => {
@@ -149,8 +124,7 @@ 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 = indexing::find_qmd_binary().ok_or_else(|| "qmd binary not found".to_string())?;
let collection = detect_collection_name(vault_path);
@@ -161,7 +135,8 @@ pub fn search_vault(
};
let limit_str = limit.to_string();
let output = Command::new(&qmd_bin)
let output = qmd
.command()
.args([
search_cmd,
query,

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)
}
@@ -145,17 +145,79 @@ pub fn seed_default_themes(vault_path: &str) {
}
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
/// Safe to call multiple times — only writes files that are missing.
/// Per-file idempotent: creates the directory if missing, writes each default
/// file only when it doesn't exist or is empty (corrupt). Never overwrites
/// existing files that have content.
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",
);
let theme_dir = Path::new(vault_path).join("theme");
if fs::create_dir_all(&theme_dir).is_err() {
return;
}
let defaults: &[(&str, &str)] = &[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
("minimal.md", MINIMAL_VAULT_THEME),
];
let mut seeded = false;
for (name, content) in defaults {
let path = theme_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
let _ = fs::write(&path, content);
seeded = true;
}
}
if seeded {
log::info!("Seeded theme/ with built-in vault themes");
}
}
/// Ensure vault theme files exist. Returns an error if the theme directory
/// cannot be created (e.g. read-only filesystem).
pub fn ensure_vault_themes(vault_path: &str) -> Result<(), 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 defaults: &[(&str, &str)] = &[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
("minimal.md", MINIMAL_VAULT_THEME),
];
for (name, content) in defaults {
let path = theme_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&path, content).map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
}
}
Ok(())
}
/// Restore default themes for a vault: seeds both `_themes/` (JSON) and
/// `theme/` (markdown notes). Per-file idempotent — never overwrites files
/// that already have content. Returns an error on read-only filesystems.
pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
// Seed _themes/ JSON files (per-file idempotent)
let themes_dir = Path::new(vault_path).join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
let json_defaults: &[(&str, &str)] = &[
("default.json", DEFAULT_THEME),
("dark.json", DARK_THEME),
("minimal.json", MINIMAL_THEME),
];
for (name, content) in json_defaults {
let path = themes_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&path, content)
.map_err(|e| format!("Failed to write _themes/{name}: {e}"))?;
}
}
// Seed theme/ markdown notes (reuses ensure_vault_themes for consistency)
ensure_vault_themes(vault_path)?;
Ok("Default themes restored".to_string())
}
/// Create a new vault theme note in `theme/` directory.
@@ -684,9 +746,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]
@@ -851,4 +918,158 @@ mod tests {
assert!(content.contains("accent-blue:"));
assert!(content.contains("editor-font-size:"));
}
#[test]
fn test_seed_vault_themes_writes_missing_files_in_existing_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
// Only default exists — dark and minimal should be seeded
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
assert!(theme_dir.join("dark.md").exists());
assert!(theme_dir.join("minimal.md").exists());
}
#[test]
fn test_seed_vault_themes_reseeds_empty_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
// Create empty file — should be re-seeded
fs::write(theme_dir.join("default.md"), "").unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
}
#[test]
fn test_seed_vault_themes_preserves_existing_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(
content.contains("#FF0000"),
"existing content must be preserved"
);
}
#[test]
fn test_ensure_vault_themes_creates_dir_and_defaults() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
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_ensure_vault_themes_reseeds_empty_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("default.md"), "").unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
}
#[test]
fn test_ensure_vault_themes_preserves_custom_themes() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("#123456"));
}
#[test]
fn test_restore_default_themes_creates_both_dirs() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let msg = restore_default_themes(vp).unwrap();
assert_eq!(msg, "Default themes restored");
// _themes/ JSON files
assert!(vault.join("_themes").join("default.json").exists());
assert!(vault.join("_themes").join("dark.json").exists());
assert!(vault.join("_themes").join("minimal.json").exists());
// theme/ markdown notes
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_restore_default_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();
restore_default_themes(vp).unwrap();
// Modify a theme file to verify it isn't overwritten
let custom = "---\nIs A: Theme\nbackground: \"#CUSTOM\"\n---\n";
fs::write(vault.join("theme").join("default.md"), custom).unwrap();
restore_default_themes(vp).unwrap();
let content = fs::read_to_string(vault.join("theme").join("default.md")).unwrap();
assert!(
content.contains("#CUSTOM"),
"must not overwrite existing content"
);
}
#[test]
fn test_restore_default_themes_fills_partial_state() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
// Create _themes/ with only default.json, theme/ with only default.md
let themes_dir = vault.join("_themes");
let theme_dir = vault.join("theme");
fs::create_dir_all(&themes_dir).unwrap();
fs::create_dir_all(&theme_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
let vp = vault.to_str().unwrap();
restore_default_themes(vp).unwrap();
// Missing files should now exist
assert!(themes_dir.join("dark.json").exists());
assert!(themes_dir.join("minimal.json").exists());
assert!(theme_dir.join("dark.md").exists());
assert!(theme_dir.join("minimal.md").exists());
// Existing files should be unchanged
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Light theme with warm"));
}
}

View File

@@ -68,6 +68,12 @@ 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>,
/// Default view mode for the note list when viewing instances of this Type.
/// Stored as a string: "all", "editor-list", or "editor-only".
pub view: Option<String>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
@@ -100,9 +106,9 @@ struct Frontmatter {
cadence: Option<String>,
#[serde(rename = "Archived")]
archived: Option<bool>,
#[serde(rename = "Trashed")]
#[serde(rename = "Trashed", alias = "trashed")]
trashed: Option<bool>,
#[serde(rename = "Trashed at")]
#[serde(rename = "Trashed at", alias = "trashed_at")]
trashed_at: Option<String>,
#[serde(rename = "Created at")]
created_at: Option<String>,
@@ -118,6 +124,10 @@ struct Frontmatter {
sidebar_label: Option<String>,
#[serde(default)]
template: Option<String>,
#[serde(default)]
sort: Option<String>,
#[serde(default)]
view: Option<String>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -163,6 +173,8 @@ const SKIP_KEYS: &[&str] = &[
"order",
"sidebar label",
"template",
"sort",
"view",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -387,6 +399,8 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
order: frontmatter.order,
sidebar_label: frontmatter.sidebar_label,
template: frontmatter.template,
sort: frontmatter.sort,
view: frontmatter.view,
word_count,
outgoing_links,
properties,
@@ -1183,6 +1197,40 @@ 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]
@@ -1271,6 +1319,39 @@ Company: Acme Corp
);
}
#[test]
fn test_parse_trashed_title_case() {
let dir = TempDir::new().unwrap();
let content = "---\nTrashed: true\nTrashed at: \"2025-02-01\"\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone.md", content);
assert!(entry.trashed);
assert!(entry.trashed_at.is_some());
}
#[test]
fn test_parse_trashed_lowercase_alias() {
let dir = TempDir::new().unwrap();
let content = "---\ntrashed: true\ntrashed_at: \"2025-02-01\"\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone.md", content);
assert!(
entry.trashed,
"lowercase 'trashed' must be parsed via alias"
);
assert!(
entry.trashed_at.is_some(),
"lowercase 'trashed_at' must be parsed via alias"
);
}
#[test]
fn test_trashed_false_when_absent() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Note\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active.md", content);
assert!(!entry.trashed);
assert!(entry.trashed_at.is_none());
}
// 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

@@ -116,6 +116,14 @@ fn strip_markdown_chars(s: &str) -> String {
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'[' if chars.peek() == Some(&'[') => {
chars.next(); // consume second '['
let inner = collect_wikilink_inner(&mut chars);
match inner.find('|') {
Some(idx) => result.push_str(&inner[idx + 1..]),
None => result.push_str(&inner),
}
}
'[' => {
let inner = collect_until(&mut chars, ']');
if chars.peek() == Some(&'(') {
@@ -131,6 +139,19 @@ fn strip_markdown_chars(s: &str) -> String {
result
}
/// Collect chars inside a wikilink until `]]`, consuming both closing brackets.
fn collect_wikilink_inner(chars: &mut std::iter::Peekable<impl Iterator<Item = char>>) -> String {
let mut buf = String::new();
while let Some(c) = chars.next() {
if c == ']' && chars.peek() == Some(&']') {
chars.next();
break;
}
buf.push(c);
}
buf
}
/// Check if a string contains a wikilink pattern `[[...]]`.
pub(super) fn contains_wikilink(s: &str) -> bool {
s.contains("[[") && s.contains("]]")
@@ -250,6 +271,16 @@ mod tests {
let snippet = extract_snippet(content);
assert!(snippet.contains("this link"));
assert!(!snippet.contains("https://example.com"));
assert!(snippet.contains("wiki link"));
assert!(!snippet.contains("[["));
assert!(!snippet.contains("]]"));
}
#[test]
fn test_extract_snippet_wikilink_alias() {
let content = "# Title\n\nDiscussed in [[meetings/standup|standup]] today.";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Discussed in standup today.");
}
#[test]
@@ -378,7 +409,20 @@ mod tests {
#[test]
fn test_strip_markdown_chars_wikilink() {
assert_eq!(strip_markdown_chars("see [[my note]]"), "see [my note]");
assert_eq!(strip_markdown_chars("see [[my note]]"), "see my note");
}
#[test]
fn test_strip_markdown_chars_wikilink_alias() {
assert_eq!(
strip_markdown_chars("visit [[project/alpha|Alpha Project]]"),
"visit Alpha Project"
);
}
#[test]
fn test_strip_markdown_chars_wikilink_unclosed() {
assert_eq!(strip_markdown_chars("see [[broken link"), "see broken link");
}
#[test]

View File

@@ -0,0 +1,273 @@
use gray_matter::engine::YAML;
use gray_matter::Matter;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// Vault-wide UI configuration stored in `config/ui.config.md`.
///
/// This file is a regular vault note with YAML frontmatter, visible in the
/// sidebar under the "Config" section and editable like any note.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct VaultConfig {
pub zoom: Option<f64>,
pub view_mode: Option<String>,
#[serde(default)]
pub tag_colors: Option<HashMap<String, String>>,
#[serde(default)]
pub status_colors: Option<HashMap<String, String>>,
#[serde(default)]
pub property_display_modes: Option<HashMap<String, String>>,
#[serde(default)]
pub hidden_sections: Option<Vec<String>>,
}
const CONFIG_DIR: &str = "config";
const CONFIG_FILENAME: &str = "ui.config.md";
fn config_path(vault_path: &str) -> std::path::PathBuf {
Path::new(vault_path).join(CONFIG_DIR).join(CONFIG_FILENAME)
}
/// Read the vault-wide UI config from `config/ui.config.md`.
/// Returns default values if the file doesn't exist.
pub fn get_vault_config(vault_path: &str) -> Result<VaultConfig, String> {
let path = config_path(vault_path);
if !path.exists() {
return Ok(VaultConfig::default());
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
parse_vault_config(&content)
}
/// Parse VaultConfig from markdown content with YAML frontmatter.
fn parse_vault_config(content: &str) -> Result<VaultConfig, String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let hash = match parsed.data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return Ok(VaultConfig::default()),
};
let json_map: serde_json::Map<String, serde_json::Value> =
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
let json = serde_json::Value::Object(json_map);
serde_json::from_value(json.clone())
.or_else(|_| {
// If direct deserialization fails, strip the `type` field and retry
let mut map = match json {
serde_json::Value::Object(m) => m,
_ => return Ok(VaultConfig::default()),
};
map.remove("type");
serde_json::from_value(serde_json::Value::Object(map))
})
.map_err(|e| format!("Failed to parse config: {e}"))
}
/// Save the vault-wide UI config to `config/ui.config.md`.
/// Creates the directory and file if they don't exist.
pub fn save_vault_config(vault_path: &str, config: VaultConfig) -> Result<(), String> {
let path = config_path(vault_path);
let dir = Path::new(vault_path).join(CONFIG_DIR);
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let content = serialize_config(&config);
std::fs::write(&path, content).map_err(|e| format!("Failed to write config: {e}"))
}
/// Serialize VaultConfig to a markdown file with YAML frontmatter.
fn serialize_config(config: &VaultConfig) -> String {
let mut lines = vec!["---".to_string(), "type: config".to_string()];
if let Some(zoom) = config.zoom {
lines.push(format!("zoom: {zoom}"));
}
if let Some(ref mode) = config.view_mode {
lines.push(format!("view_mode: {mode}"));
}
append_string_map(&mut lines, "tag_colors", config.tag_colors.as_ref());
append_string_map(&mut lines, "status_colors", config.status_colors.as_ref());
append_string_map(
&mut lines,
"property_display_modes",
config.property_display_modes.as_ref(),
);
if let Some(ref sections) = config.hidden_sections {
if !sections.is_empty() {
lines.push("hidden_sections:".to_string());
for s in sections {
lines.push(format!(" - {s}"));
}
}
}
lines.push("---".to_string());
lines.join("\n") + "\n"
}
/// Append a YAML map section with sorted keys for stable output.
fn append_string_map(lines: &mut Vec<String>, key: &str, map: Option<&HashMap<String, String>>) {
if let Some(m) = map {
if !m.is_empty() {
lines.push(format!("{key}:"));
let mut entries: Vec<_> = m.iter().collect();
entries.sort_by_key(|(k, _)| k.to_owned());
for (k, v) in entries {
lines.push(format!(" {}: {}", yaml_safe_key(k), yaml_safe_value(v)));
}
}
}
}
/// Quote a YAML key if it contains special characters.
fn yaml_safe_key(key: &str) -> String {
if key.contains(':') || key.contains('#') || key.contains(' ') {
format!("\"{}\"", key.replace('"', "\\\""))
} else {
key.to_string()
}
}
/// Quote a YAML value if it contains special characters.
fn yaml_safe_value(value: &str) -> String {
if value.contains(':')
|| value.contains('#')
|| value.starts_with('"')
|| value.starts_with('\'')
{
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value.to_string()
}
}
/// Convert gray_matter::Pod to serde_json::Value.
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {
gray_matter::Pod::String(s) => serde_json::Value::String(s),
gray_matter::Pod::Integer(i) => serde_json::json!(i),
gray_matter::Pod::Float(f) => serde_json::json!(f),
gray_matter::Pod::Boolean(b) => serde_json::Value::Bool(b),
gray_matter::Pod::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(pod_to_json).collect())
}
gray_matter::Pod::Hash(map) => {
let obj: serde_json::Map<String, serde_json::Value> =
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
serde_json::Value::Object(obj)
}
gray_matter::Pod::Null => serde_json::Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_returns_defaults() {
let config = parse_vault_config("").unwrap();
assert!(config.zoom.is_none());
assert!(config.view_mode.is_none());
assert!(config.tag_colors.is_none());
}
#[test]
fn parse_full_config() {
let content = r#"---
type: config
zoom: 1.1
view_mode: all
tag_colors:
engineering: blue
personal: green
status_colors:
Active: green
Done: blue
property_display_modes:
deadline: date
hidden_sections:
- Archived
- Trash
---
"#;
let config = parse_vault_config(content).unwrap();
assert_eq!(config.zoom, Some(1.1));
assert_eq!(config.view_mode.as_deref(), Some("all"));
let tags = config.tag_colors.unwrap();
assert_eq!(tags.get("engineering").unwrap(), "blue");
assert_eq!(tags.get("personal").unwrap(), "green");
let statuses = config.status_colors.unwrap();
assert_eq!(statuses.get("Active").unwrap(), "green");
let props = config.property_display_modes.unwrap();
assert_eq!(props.get("deadline").unwrap(), "date");
let hidden = config.hidden_sections.unwrap();
assert_eq!(hidden, vec!["Archived", "Trash"]);
}
#[test]
fn roundtrip_serialization() {
let mut tag_colors = HashMap::new();
tag_colors.insert("work".to_string(), "blue".to_string());
let config = VaultConfig {
zoom: Some(1.2),
view_mode: Some("editor-only".to_string()),
tag_colors: Some(tag_colors),
status_colors: None,
property_display_modes: None,
hidden_sections: Some(vec!["Trash".to_string()]),
};
let serialized = serialize_config(&config);
let parsed = parse_vault_config(&serialized).unwrap();
assert_eq!(parsed.zoom, Some(1.2));
assert_eq!(parsed.view_mode.as_deref(), Some("editor-only"));
assert_eq!(parsed.tag_colors.unwrap().get("work").unwrap(), "blue");
assert_eq!(parsed.hidden_sections.unwrap(), vec!["Trash"]);
}
#[test]
fn get_config_missing_file() {
let dir = tempfile::TempDir::new().unwrap();
let config = get_vault_config(dir.path().to_str().unwrap()).unwrap();
assert!(config.zoom.is_none());
}
#[test]
fn save_and_read_config() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let mut status_colors = HashMap::new();
status_colors.insert("Active".to_string(), "green".to_string());
let config = VaultConfig {
zoom: Some(0.9),
view_mode: None,
tag_colors: None,
status_colors: Some(status_colors),
property_display_modes: None,
hidden_sections: None,
};
save_vault_config(vault_path, config).unwrap();
let loaded = get_vault_config(vault_path).unwrap();
assert_eq!(loaded.zoom, Some(0.9));
assert_eq!(
loaded.status_colors.unwrap().get("Active").unwrap(),
"green"
);
}
#[test]
fn yaml_safe_key_quoting() {
assert_eq!(yaml_safe_key("simple"), "simple");
assert_eq!(yaml_safe_key("has space"), "\"has space\"");
assert_eq!(yaml_safe_key("has:colon"), "\"has:colon\"");
}
}

View File

@@ -7,7 +7,7 @@
"frontendDist": "../dist",
"devUrl": "http://localhost:5202",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp"
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp && bash scripts/bundle-qmd.sh"
},
"app": {
"withGlobalTauri": true,
@@ -38,7 +38,8 @@
"targets": "all",
"createUpdaterArtifacts": true,
"resources": {
"resources/mcp-server/**/*": "mcp-server/"
"resources/mcp-server/**/*": "mcp-server/",
"resources/qmd/**/*": "qmd/"
},
"icon": [
"icons/32x32.png",

View File

@@ -61,3 +61,14 @@
.tab-status-pulse {
animation: tab-status-pulse 1.2s ease-in-out infinite;
}
/* AI highlight animation — applied by useAiActivity when MCP calls ui_highlight */
@keyframes ai-highlight-glow {
0% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0.8); }
50% { box-shadow: inset 0 0 8px 2px rgba(99, 102, 241, 0.4); }
100% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0); }
}
.ai-highlight {
animation: ai-highlight-glow 0.8s ease-out;
}

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Sidebar } from './components/Sidebar'
import { NoteList } from './components/NoteList'
import { Editor } from './components/Editor'
@@ -9,15 +9,15 @@ import { CommandPalette } from './components/CommandPalette'
import { SearchPanel } from './components/SearchPanel'
import { Toast } from './components/Toast'
import { CommitDialog } from './components/CommitDialog'
import { PulseView } from './components/PulseView'
import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { useMcpRegistration } from './hooks/useMcpRegistration'
import { useMcpStatus } from './hooks/useMcpStatus'
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,18 +25,26 @@ 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 { useVaultConfig } from './hooks/useVaultConfig'
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 { useAiActivity } from './hooks/useAiActivity'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import { extractOutgoingLinks } from './utils/wikilinks'
import type { SidebarSelection } from './types'
import type { SidebarSelection, VaultEntry } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries } from './utils/noteListHelpers'
import './App.css'
// Type declaration for mock content storage
@@ -77,34 +85,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()
@@ -124,10 +104,11 @@ function App() {
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const vault = useVaultLoader(resolvedPath)
useVaultConfig(resolvedPath)
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent)
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent)
useMcpRegistration(resolvedPath, setToastMessage)
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
@@ -135,17 +116,78 @@ 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(async () => {
let files = autoSync.conflictFiles
// If no cached conflicts, check directly — there may be pre-existing
// conflicts from a prior session that the pull flow didn't detect.
if (files.length === 0) {
try {
files = isTauri()
? await invoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
: await mockInvoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
} catch {
return
}
if (files.length === 0) {
setToastMessage('No merge conflicts to resolve')
return
}
}
autoSync.pausePull()
conflictResolver.initFiles(files)
dialogs.openConflictResolver()
}, [autoSync, conflictResolver, dialogs, resolvedPath])
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)
@@ -185,53 +227,72 @@ 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() }
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
const openNoteByPath = useCallback((path: string) => {
const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
if (entry) {
notes.handleSelectNote(entry)
} else {
// Entry not yet in vault (just created) — reload then open
vault.reloadVault().then(freshEntries => {
const fresh = freshEntries.find((e: VaultEntry) => e.path === path || e.path === `${resolvedPath}/${path}`)
if (fresh) notes.handleSelectNote(fresh)
})
}
window.addEventListener('mouseup', handleMouseBack)
}, [vault, notes, resolvedPath])
// 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 aiActivity = useAiActivity({
onOpenNote: openNoteByPath,
onOpenTab: openNoteByPath,
onSetFilter: (filterType) => {
setSelection({ kind: 'sectionGroup', type: filterType })
},
onVaultChanged: () => { vault.reloadVault() },
})
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
// Agent file operation handlers: auto-open created notes, live-refresh modified notes
const handleAgentFileCreated = useCallback((relativePath: string) => {
vault.reloadVault().then(freshEntries => {
const entry = freshEntries.find((e: VaultEntry) => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
if (entry) notes.handleSelectNote(entry)
})
}, [vault, notes, resolvedPath])
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()
}
const handleAgentFileModified = useCallback((relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const matchPath = notes.tabs.some(t => t.entry.path === relativePath) ? relativePath : fullPath
if (notes.tabs.some(t => t.entry.path === matchPath)) {
vault.reloadVault()
}
window.addEventListener('wheel', handleWheel, { passive: true })
}, [vault, notes, resolvedPath])
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)
@@ -296,6 +357,14 @@ function App() {
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")
@@ -303,7 +372,20 @@ function App() {
setToastMessage('Could not check for updates')
}
// 'available' → UpdateBanner handles it automatically
}, [updateActions, setToastMessage])
}, [updateActions, updateStatus.state, setToastMessage])
const handleRestoreDefaultThemes = useCallback(async () => {
if (!resolvedPath) return
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const msg = await tauriInvoke<string>('restore_default_themes', { vaultPath: resolvedPath })
await vault.reloadVault()
await themeManager.reloadThemes()
setToastMessage(msg)
} catch (err) {
setToastMessage(`Failed to restore themes: ${err}`)
}
}, [resolvedPath, vault, themeManager, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
@@ -321,7 +403,9 @@ function App() {
onOpenSettings: dialogs.openSettings,
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
onCommitPush: commitFlow.openCommitDialog,
onResolveConflicts: handleOpenConflictResolver,
onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: () => rawToggleRef.current(),
@@ -335,9 +419,13 @@ function App() {
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
onSwitchTheme: themeManager.switchTheme,
onCreateTheme: async () => {
await themeManager.createTheme()
await vault.reloadVault()
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)
@@ -347,15 +435,30 @@ function App() {
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
isUpdating: updateStatus.state === 'downloading' || updateStatus.state === 'ready',
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
onRestoreDefaultThemes: handleRestoreDefaultThemes,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
const aiNoteList = useMemo<NoteListItem[]>(() => {
return filterEntries(vault.entries, selection).map(e => ({
path: e.path, title: e.title, type: e.isA ?? 'Note',
}))
}, [vault.entries, selection])
const aiNoteListFilter = useMemo(() => {
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
if (selection.kind === 'topic') return { type: null, query: selection.entry.title }
if (selection.kind === 'entity') return { type: null, query: selection.entry.title }
return { type: null, query: '' }
}, [selection])
// Show welcome/onboarding screen when vault doesn't exist
if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') {
const defaultPath = onboarding.state.defaultPath
@@ -391,20 +494,28 @@ 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} onRenameSection={entryActions.handleRenameSection} 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} isGitVault={!vault.modifiedFilesError} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{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} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={(relativePath) => {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = vault.entries.find(e => e.path === fullPath || e.path === relativePath)
if (entry) notes.handleSelectNote(entry)
}} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<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} />
</>
)}
<div className="app__editor">
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
@@ -431,6 +542,8 @@ function App() {
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onDeleteNote={handleDeleteNote}
@@ -448,17 +561,30 @@ function App() {
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
isDarkTheme={themeManager.isDark}
onFileCreated={handleAgentFileCreated}
onFileModified={handleAgentFileModified}
/>
</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} onRemoveVault={vaultSwitcher.removeVault} />
<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} onRetryIndexing={indexing.retryIndexing} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<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" />)
it('uses lighter background for open_note tool', () => {
render(<AiActionCard {...defaults} tool="open_note" label="Opening note" />)
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,8 @@
import type { ReactNode } from 'react'
import { type KeyboardEvent, type ReactNode, useCallback } from 'react'
import {
PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle,
CircleNotch, CheckCircle, XCircle,
PencilSimple, MagnifyingGlass, Trash, ChartBar, Eye,
CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown,
Terminal, File, FolderOpen, NotePencil,
} from '@phosphor-icons/react'
export type AiActionStatus = 'pending' | 'done' | 'error'
@@ -11,24 +12,33 @@ 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> = {
create_note: (s) => <PencilSimple size={s} />,
edit_note_frontmatter: (s) => <PencilSimple size={s} />,
append_to_note: (s) => <PencilSimple size={s} />,
// Native Claude Code tools
Bash: (s) => <Terminal size={s} />,
Write: (s) => <PencilSimple size={s} />,
Edit: (s) => <NotePencil size={s} />,
Read: (s) => <File size={s} />,
Glob: (s) => <FolderOpen size={s} />,
Grep: (s) => <MagnifyingGlass size={s} />,
// Laputa MCP tools
search_notes: (s) => <MagnifyingGlass size={s} />,
list_notes: (s) => <MagnifyingGlass size={s} />,
link_notes: (s) => <Link size={s} />,
get_vault_context: (s) => <ChartBar size={s} />,
get_note: (s) => <File size={s} />,
open_note: (s) => <Eye size={s} />,
// Legacy tools (for backward compatibility with existing messages)
create_note: (s) => <PencilSimple size={s} />,
delete_note: (s) => <Trash size={s} />,
vault_context: (s) => <ChartBar size={s} />,
ui_open_note: (s) => <Eye size={s} />,
ui_open_tab: (s) => <Eye size={s} />,
ui_highlight: (s) => <Sparkle size={s} />,
ui_set_filter: (s) => <Sparkle size={s} />,
}
const DEFAULT_ICON: IconRenderer = (s) => <PencilSimple size={s} />
@@ -43,27 +53,122 @@ 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>
)
}
/** Whether this tool is a Laputa UI-only tool (lighter styling). */
function isUiOnlyTool(tool: string): boolean {
return tool === 'open_note'
}
export function AiActionCard({
tool, label, path, status, input, output, expanded, onToggle, onOpenNote,
}: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
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: isUiOnlyTool(tool) ? '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

@@ -2,15 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiMessage } from './AiMessage'
vi.mock('./MarkdownContent', () => ({
MarkdownContent: ({ content }: { content: string }) => <div data-testid="markdown-content">{content}</div>,
}))
describe('AiMessage', () => {
it('renders user message', () => {
render(<AiMessage userMessage="Hello AI" actions={[]} />)
expect(screen.getByText('Hello AI')).toBeTruthy()
})
it('renders response text', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the answer" />)
expect(screen.getByText('Here is the answer')).toBeTruthy()
it('renders response as markdown', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the **answer**" />)
expect(screen.getByTestId('markdown-content')).toBeTruthy()
expect(screen.getByText('Here is the **answer**')).toBeTruthy()
})
it('shows undo button with response', () => {
@@ -18,23 +23,31 @@ describe('AiMessage', () => {
expect(screen.getByTestId('undo-button')).toBeTruthy()
})
it('renders reasoning toggle collapsed by default', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
it('shows reasoning expanded while streaming (reasoningDone=false)', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." reasoningDone={false} actions={[]} />)
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('expands reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
expect(screen.getByText('Thinking about it...')).toBeTruthy()
})
it('collapses reasoning on second click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." actions={[]} />)
it('auto-collapses reasoning when reasoningDone=true', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone actions={[]} />)
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('expands collapsed reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone actions={[]} />)
// Starts collapsed (reasoningDone=true)
expect(screen.queryByTestId('reasoning-content')).toBeNull()
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
})
it('collapses expanded reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone={false} actions={[]} />)
// Starts expanded (reasoningDone=false)
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
@@ -44,8 +57,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 +70,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 +101,69 @@ describe('AiMessage', () => {
render(<AiMessage userMessage="Ask" actions={[]} />)
expect(screen.queryByTestId('ai-action-card')).toBeNull()
})
it('renders reference pills in user bubble', () => {
render(
<AiMessage
userMessage="Tell me about this"
references={[
{ title: 'Marco', path: 'person/marco.md', type: 'Person' },
{ title: 'Project X', path: 'project/x.md', type: 'Project' },
]}
actions={[]}
/>,
)
const pills = screen.getAllByTestId('message-reference-pill')
expect(pills).toHaveLength(2)
expect(pills[0].textContent).toBe('Marco')
expect(pills[1].textContent).toBe('Project X')
})
it('does not render pills when no references', () => {
render(<AiMessage userMessage="Hello" actions={[]} />)
expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0)
})
it('does not render pills when references array is empty', () => {
render(<AiMessage userMessage="Hello" references={[]} actions={[]} />)
expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0)
})
it('calls onOpenNote when a reference pill is clicked', () => {
const onOpenNote = vi.fn()
render(
<AiMessage
userMessage="Check this"
references={[{ title: 'Alpha', path: 'note/alpha.md', type: 'Note' }]}
actions={[]}
onOpenNote={onOpenNote}
/>,
)
fireEvent.click(screen.getByTestId('message-reference-pill'))
expect(onOpenNote).toHaveBeenCalledWith('note/alpha.md')
})
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,24 +1,63 @@
import { useState } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
import { AiActionCard, type AiActionStatus } from './AiActionCard'
import { MarkdownContent } from './MarkdownContent'
import type { NoteReference } from '../utils/ai-context'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
export interface AiAction {
tool: string
toolId: string
label: string
path?: string
status: AiActionStatus
input?: string
output?: string
}
export interface AiMessageProps {
userMessage: string
references?: NoteReference[]
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
response?: string
isStreaming?: boolean
onOpenNote?: (path: string) => void
}
function UserBubble({ content }: { content: string }) {
function ReferencePill({ reference, onClick }: {
reference: NoteReference
onClick?: (path: string) => void
}) {
const color = getTypeColor(reference.type)
const lightColor = getTypeLightColor(reference.type)
return (
<button
className="inline-flex items-center border-none cursor-pointer transition-opacity hover:opacity-80"
style={{
background: lightColor,
color,
borderRadius: 9999,
padding: '1px 8px',
fontSize: 11,
fontWeight: 500,
fontFamily: 'inherit',
lineHeight: 1.4,
}}
onClick={() => onClick?.(reference.path)}
data-testid="message-reference-pill"
>
{reference.title}
</button>
)
}
function UserBubble({ content, references, onOpenNote }: {
content: string
references?: NoteReference[]
onOpenNote?: (path: string) => void
}) {
return (
<div className="flex justify-end" style={{ marginBottom: 8 }}>
<div
@@ -32,6 +71,13 @@ function UserBubble({ content }: { content: string }) {
lineHeight: 1.5,
}}
>
{references && references.length > 0 && (
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
{references.map(ref => (
<ReferencePill key={ref.path} reference={ref} onClick={onOpenNote} />
))}
</div>
)}
{content}
</div>
</div>
@@ -41,6 +87,14 @@ function UserBubble({ content }: { content: string }) {
function ReasoningBlock({ text, expanded, onToggle }: {
text: string; expanded: boolean; onToggle: () => void
}) {
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (expanded && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [expanded, text])
return (
<div style={{ marginBottom: 8 }}>
<button
@@ -55,8 +109,9 @@ function ReasoningBlock({ text, expanded, onToggle }: {
</button>
{expanded && (
<div
ref={contentRef}
className="text-muted-foreground"
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px' }}
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px', maxHeight: 200, overflowY: 'auto' }}
data-testid="reasoning-content"
>
{text}
@@ -66,18 +121,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}
/>
))}
@@ -88,7 +150,7 @@ function ActionCardsList({ actions, onOpenNote }: {
function ResponseBlock({ text }: { text: string }) {
return (
<div style={{ marginBottom: 4 }}>
<div style={{ fontSize: 13, lineHeight: 1.6 }}>{text}</div>
<MarkdownContent content={text} />
<button
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
@@ -113,20 +175,43 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
const [reasoningExpanded, setReasoningExpanded] = useState(false)
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
// Auto: expanded while reasoning streams, collapsed once done
// User can manually toggle to override the auto state
const autoExpanded = !reasoningDone
const reasoningExpanded = userOverride ? !autoExpanded : autoExpanded
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 }}>
<UserBubble content={userMessage} />
<UserBubble content={userMessage} references={references} onOpenNote={onOpenNote} />
{reasoning && (
<ReasoningBlock
text={reasoning}
expanded={reasoningExpanded}
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
onToggle={() => setUserOverride(prev => !prev)}
/>
)}
{actions.length > 0 && (
<ActionCardsList
actions={actions}
onOpenNote={onOpenNote}
expandedIds={expandedActions}
onToggleExpand={toggleAction}
/>
)}
{actions.length > 0 && <ActionCardsList actions={actions} onOpenNote={onOpenNote} />}
{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,8 +1,9 @@
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'
import { collectLinkedEntries, buildContextualPrompt } from '../utils/ai-context'
import { WikilinkChatInput } from './WikilinkChatInput'
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
export type { AiAgentMessage } from '../hooks/useAiAgent'
@@ -10,10 +11,15 @@ export type { AiAgentMessage } from '../hooks/useAiAgent'
interface AiPanelProps {
onClose: () => void
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
vaultPath: string
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
allContent?: Record<string, string>
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
}
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
@@ -103,53 +109,11 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
)
}
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext }: {
input: string; onInputChange: (v: string) => void
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
isActive: boolean; hasContext: boolean
}) {
const sendDisabled = isActive || !input.trim()
return (
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<input
value={input}
onChange={e => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
className="flex-1 border border-border bg-transparent text-foreground"
style={{
fontSize: 13, borderRadius: 8, padding: '8px 10px',
outline: 'none', fontFamily: 'inherit',
}}
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
disabled={isActive}
data-testid="agent-input"
/>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={{
background: sendDisabled ? 'var(--muted)' : 'var(--primary)',
color: sendDisabled ? 'var(--muted-foreground)' : 'white',
borderRadius: 8, width: 32, height: 34,
cursor: sendDisabled ? 'not-allowed' : 'pointer',
}}
onClick={onSend}
disabled={sendDisabled}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
)
}
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const panelRef = useRef<HTMLElement>(null)
const linkedEntries = useMemo(() => {
if (!activeEntry || !entries) return []
@@ -157,32 +121,74 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
}, [activeEntry, entries])
const contextPrompt = useMemo(() => {
if (!activeEntry || !allContent) return undefined
return buildContextualPrompt(activeEntry, linkedEntries, allContent)
}, [activeEntry, linkedEntries, allContent])
if (!activeEntry || !allContent || !entries) return undefined
return buildContextSnapshot({
activeEntry,
allContent,
openTabs,
noteList,
noteListFilter,
entries,
references: pendingRefs.length > 0 ? pendingRefs : undefined,
})
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
const agent = useAiAgent(vaultPath, contextPrompt)
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
}), [onFileCreated, onFileModified])
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
const handleSend = () => {
if (!input.trim() || isActive) return
agent.sendMessage(input)
setInput('')
}
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(timer)
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
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 = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return
setPendingRefs(references)
agent.sendMessage(text, references)
setInput('')
}, [isActive, agent])
return (
<aside
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
ref={panelRef}
tabIndex={-1}
className="flex flex-1 flex-col overflow-hidden bg-background text-foreground"
style={{
outline: 'none',
borderLeft: isActive
? '2px solid var(--accent-blue, #3b82f6)'
: '1px solid var(--border)',
animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: 'border-color 0.3s ease',
}}
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
{activeEntry && (
@@ -194,14 +200,39 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
onOpenNote={onOpenNote}
hasContext={hasContext}
/>
<InputBar
input={input}
onInputChange={setInput}
onSend={handleSend}
onKeyDown={handleKeyDown}
isActive={isActive}
hasContext={hasContext}
/>
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<WikilinkChatInput
entries={entries ?? []}
value={input}
onChange={setInput}
onSend={handleSend}
disabled={isActive}
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
inputRef={inputRef}
/>
</div>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={{
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
borderRadius: 8, width: 32, height: 34,
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
}}
onClick={() => handleSend(input, pendingRefs)}
disabled={isActive || !input.trim()}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ColorSwatch, ColorEditableValue } from './ColorInput'
describe('ColorSwatch', () => {
it('renders a swatch button with the given color', () => {
render(<ColorSwatch color="#3b82f6" />)
const swatch = screen.getByTestId('color-swatch')
expect(swatch).toBeTruthy()
// Browser normalizes hex to rgb() in computed style
expect(swatch.style.background).toBeTruthy()
})
it('contains a hidden color input', () => {
render(<ColorSwatch color="#ff0000" />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
expect(input.type).toBe('color')
expect(input.value).toBe('#ff0000')
})
it('calls onChange when color is picked', () => {
const onChange = vi.fn()
render(<ColorSwatch color="#ff0000" onChange={onChange} />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
fireEvent.change(input, { target: { value: '#00ff00' } })
expect(onChange).toHaveBeenCalledWith('#00ff00')
})
it('opens color input on Enter key', () => {
const clickSpy = vi.fn()
render(<ColorSwatch color="#ff0000" />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
input.click = clickSpy
const swatch = screen.getByTestId('color-swatch')
fireEvent.keyDown(swatch, { key: 'Enter' })
expect(clickSpy).toHaveBeenCalled()
})
it('opens color input on Space key', () => {
const clickSpy = vi.fn()
render(<ColorSwatch color="#ff0000" />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
input.click = clickSpy
const swatch = screen.getByTestId('color-swatch')
fireEvent.keyDown(swatch, { key: ' ' })
expect(clickSpy).toHaveBeenCalled()
})
})
describe('ColorEditableValue', () => {
it('shows swatch when value is a valid hex color', () => {
render(<ColorEditableValue value="#3b82f6" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.getByTestId('color-swatch')).toBeTruthy()
})
it('does not show swatch when value is not a color', () => {
render(<ColorEditableValue value="hello world" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.queryByTestId('color-swatch')).toBeNull()
})
it('does not show swatch for empty string', () => {
render(<ColorEditableValue value="" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.queryByTestId('color-swatch')).toBeNull()
})
it('shows swatch in edit mode for valid color', () => {
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.getByTestId('color-swatch')).toBeTruthy()
expect(screen.getByTestId('color-text-input')).toBeTruthy()
})
it('updates value when color is picked', () => {
const onSave = vi.fn()
render(<ColorEditableValue value="#ff0000" isEditing={false} onStartEdit={vi.fn()} onSave={onSave} onCancel={vi.fn()} />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
fireEvent.change(input, { target: { value: '#00ff00' } })
expect(onSave).toHaveBeenCalledWith('#00ff00')
})
it('text field works independently in edit mode', () => {
const onSave = vi.fn()
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={onSave} onCancel={vi.fn()} />)
const textInput = screen.getByTestId('color-text-input') as HTMLInputElement
fireEvent.change(textInput, { target: { value: '#0000ff' } })
fireEvent.keyDown(textInput, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('#0000ff')
})
it('Escape cancels edit', () => {
const onCancel = vi.fn()
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={onCancel} />)
const textInput = screen.getByTestId('color-text-input') as HTMLInputElement
fireEvent.keyDown(textInput, { key: 'Escape' })
expect(onCancel).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,113 @@
import { useState, useRef, useCallback } from 'react'
import { isValidCssColor, toHexColor } from '../utils/colorUtils'
/**
* Inline color swatch button that opens a native color picker.
* Shows nothing if the value is not a valid CSS color.
*/
export function ColorSwatch({ color, onChange }: {
color: string
onChange?: (hex: string) => void
}) {
const hex = toHexColor(color) ?? '#000000'
const inputRef = useRef<HTMLInputElement>(null)
const handleClick = useCallback(() => {
inputRef.current?.click()
}, [])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value)
}, [onChange])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
inputRef.current?.click()
}
}, [])
return (
<span className="inline-flex shrink-0 items-center">
<button
type="button"
className="relative size-4 shrink-0 cursor-pointer rounded-[3px] border border-border p-0 transition-shadow hover:ring-1 hover:ring-primary focus:outline-none focus:ring-2 focus:ring-primary"
style={{ background: color }}
onClick={handleClick}
onKeyDown={handleKeyDown}
title="Open color picker"
aria-label={`Color: ${color}. Click to open color picker`}
data-testid="color-swatch"
>
<input
ref={inputRef}
type="color"
value={hex}
onChange={handleChange}
className="pointer-events-none absolute inset-0 opacity-0"
tabIndex={-1}
aria-hidden="true"
data-testid="color-picker-input"
/>
</button>
</span>
)
}
/**
* Editable text field with an inline color swatch.
* The swatch only appears when the value is a valid CSS color.
*/
export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCancel }: {
value: string
isEditing: boolean
onStartEdit: () => void
onSave: (newValue: string) => void
onCancel: () => void
}) {
const [editValue, setEditValue] = useState(value)
const showSwatch = isValidCssColor(isEditing ? editValue : value)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') onSave(editValue)
else if (e.key === 'Escape') { setEditValue(value); onCancel() }
}
const handlePickerChange = useCallback((hex: string) => {
if (isEditing) {
setEditValue(hex)
}
onSave(hex)
}, [isEditing, onSave])
if (isEditing) {
return (
<span className="flex w-full items-center gap-1.5">
{showSwatch && <ColorSwatch color={editValue} onChange={handlePickerChange} />}
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSave(editValue)}
autoFocus
data-testid="color-text-input"
/>
</span>
)
}
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{showSwatch && <ColorSwatch color={value} onChange={handlePickerChange} />}
<span
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
>
{value || '\u2014'}
</span>
</span>
)
}

View File

@@ -165,4 +165,64 @@ describe('CommandPalette', () => {
expect(screen.getByText('↵ select')).toBeInTheDocument()
expect(screen.getByText('esc close')).toBeInTheDocument()
})
describe('relevance ranking', () => {
const relevanceCommands: CommandAction[] = [
makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note' }),
makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }),
makeCommand({ id: 'switch-theme', label: 'Switch Theme', group: 'Appearance', keywords: ['dark', 'light'] }),
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }),
]
function getVisibleLabels() {
return screen.getAllByText(
(_content, el) =>
el?.tagName === 'SPAN' &&
el.classList.contains('text-foreground') &&
!!el.textContent,
).map(el => el.textContent)
}
it('ranks "Toggle Raw Editor" before "Create New Note" for query "raw"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'raw' } })
const labels = getVisibleLabels()
const rawIdx = labels.indexOf('Toggle Raw Editor')
const createIdx = labels.indexOf('Create New Note')
expect(rawIdx).toBeGreaterThanOrEqual(0)
expect(createIdx).toBeGreaterThanOrEqual(0)
expect(rawIdx).toBeLessThan(createIdx)
})
it('ranks "Create New Note" first for query "new note"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'new note' } })
const labels = getVisibleLabels()
expect(labels[0]).toBe('Create New Note')
})
it('ranks theme commands first for query "theme"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'theme' } })
const labels = getVisibleLabels()
expect(labels[0]).toBe('Switch Theme')
})
it('preserves default section order with empty query', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
const groupHeaders = screen.getAllByText(
(_content, el) =>
el?.tagName === 'DIV' &&
el.classList.contains('uppercase') &&
!!el.textContent,
).map(el => el.textContent)
// Default order: Navigation < Note < View < Appearance
expect(groupHeaders).toEqual(['Navigation', 'Note', 'View', 'Appearance'])
})
})
})

View File

@@ -30,16 +30,21 @@ function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null {
return null
}
function groupResults(commands: CommandAction[]): { group: CommandGroup; items: CommandAction[] }[] {
function groupResults(
commands: CommandAction[],
byRelevance: boolean,
): { group: CommandGroup; items: CommandAction[] }[] {
const map = new Map<CommandGroup, CommandAction[]>()
for (const cmd of commands) {
const list = map.get(cmd.group)
if (list) list.push(cmd)
else map.set(cmd.group, [cmd])
}
return Array.from(map.entries())
.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
.map(([group, items]) => ({ group, items }))
const entries = Array.from(map.entries())
if (!byRelevance) {
entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
}
return entries.map(([group, items]) => ({ group, items }))
}
export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) {
@@ -70,7 +75,8 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
.map(r => r.command)
}, [enabledCommands, query])
const groups = useMemo(() => groupResults(filtered), [filtered])
const hasQuery = !!query.trim()
const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery])
const flatList = useMemo(() => groups.flatMap(g => g.items), [groups])
useEffect(() => {

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ConflictResolverModal } from './ConflictResolverModal'
import type { ConflictFileState } from '../hooks/useConflictResolver'
function makeFileStates(overrides?: Partial<ConflictFileState>[]): ConflictFileState[] {
const defaults: ConflictFileState[] = [
{ file: 'notes/project.md', resolution: null, resolving: false },
{ file: 'notes/plan.md', resolution: null, resolving: false },
]
if (!overrides) return defaults
return defaults.map((d, i) => ({ ...d, ...(overrides[i] ?? {}) }))
}
function renderModal(overrides: Record<string, unknown> = {}) {
const props = {
open: true,
fileStates: makeFileStates(),
allResolved: false,
committing: false,
error: null,
onResolveFile: vi.fn(),
onOpenInEditor: vi.fn(),
onCommit: vi.fn(),
onClose: vi.fn(),
...overrides,
}
return { ...render(<ConflictResolverModal {...props} />), props }
}
describe('ConflictResolverModal', () => {
it('renders conflict file list when open', () => {
renderModal()
expect(screen.getByText('Resolve Merge Conflicts')).toBeInTheDocument()
expect(screen.getByTestId('conflict-file-list')).toBeInTheDocument()
expect(screen.getByTestId('conflict-file-notes/project.md')).toBeInTheDocument()
expect(screen.getByTestId('conflict-file-notes/plan.md')).toBeInTheDocument()
})
it('shows file count description', () => {
renderModal()
expect(screen.getByText(/2 files have merge conflicts/)).toBeInTheDocument()
})
it('shows singular description for one file', () => {
renderModal({ fileStates: [{ file: 'note.md', resolution: null, resolving: false }] })
expect(screen.getByText(/1 file has merge conflicts/)).toBeInTheDocument()
})
it('calls onResolveFile when Keep mine is clicked', () => {
const { props } = renderModal()
fireEvent.click(screen.getByTestId('resolve-ours-notes/project.md'))
expect(props.onResolveFile).toHaveBeenCalledWith('notes/project.md', 'ours')
})
it('calls onResolveFile when Keep theirs is clicked', () => {
const { props } = renderModal()
fireEvent.click(screen.getByTestId('resolve-theirs-notes/plan.md'))
expect(props.onResolveFile).toHaveBeenCalledWith('notes/plan.md', 'theirs')
})
it('calls onOpenInEditor when Open in editor is clicked', () => {
const { props } = renderModal()
fireEvent.click(screen.getByTestId('resolve-open-notes/project.md'))
expect(props.onOpenInEditor).toHaveBeenCalledWith('notes/project.md')
})
it('hides Open in editor for binary files', () => {
renderModal({
fileStates: [{ file: 'images/photo.png', resolution: null, resolving: false }],
})
expect(screen.queryByTestId('resolve-open-images/photo.png')).not.toBeInTheDocument()
// Keep mine/theirs should still be visible
expect(screen.getByTestId('resolve-ours-images/photo.png')).toBeInTheDocument()
})
it('Commit & continue button is disabled when not all resolved', () => {
renderModal({ allResolved: false })
expect(screen.getByTestId('conflict-commit-btn')).toBeDisabled()
})
it('Commit & continue button is enabled when all resolved', () => {
renderModal({ allResolved: true })
expect(screen.getByTestId('conflict-commit-btn')).toBeEnabled()
})
it('calls onCommit when Commit & continue is clicked', () => {
const { props } = renderModal({ allResolved: true })
fireEvent.click(screen.getByTestId('conflict-commit-btn'))
expect(props.onCommit).toHaveBeenCalled()
})
it('shows committing state', () => {
renderModal({ allResolved: true, committing: true })
expect(screen.getByText('Committing…')).toBeInTheDocument()
expect(screen.getByTestId('conflict-commit-btn')).toBeDisabled()
})
it('shows error message', () => {
renderModal({ error: 'git commit failed: user.email not set' })
expect(screen.getByTestId('conflict-error')).toHaveTextContent('git commit failed')
})
it('shows resolution labels after file is resolved', () => {
renderModal({
fileStates: makeFileStates([{ resolution: 'ours' }, { resolution: 'theirs' }]),
})
expect(screen.getByText('Keeping mine')).toBeInTheDocument()
expect(screen.getByText('Keeping theirs')).toBeInTheDocument()
})
it('shows spinner when file is resolving', () => {
renderModal({
fileStates: [{ file: 'note.md', resolution: null, resolving: true }],
})
// Buttons hidden during resolving, spinner shown instead
expect(screen.queryByTestId('resolve-ours-note.md')).not.toBeInTheDocument()
})
it('calls onClose when Cancel is clicked', () => {
const { props } = renderModal()
fireEvent.click(screen.getByText('Cancel'))
expect(props.onClose).toHaveBeenCalled()
})
it('shows keyboard shortcut hint', () => {
renderModal()
expect(screen.getByText(/K = keep mine/)).toBeInTheDocument()
})
})

View File

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

View File

@@ -2,20 +2,8 @@ import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPanel'
import type { VaultEntry } from '../types'
// Mock localStorage (jsdom's may be incomplete)
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (i: number) => Object.keys(store)[i] ?? null,
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
import { initDisplayModeOverrides } from '../utils/propertyTypes'
// Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM
beforeAll(() => {
@@ -51,7 +39,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})
@@ -847,7 +835,12 @@ describe('DynamicPropertiesPanel', () => {
describe('display mode override', () => {
beforeEach(() => {
localStorageMock.clear()
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
vi.fn(),
)
initDisplayModeOverrides({})
})
it('renders display mode trigger on property rows', () => {
@@ -880,7 +873,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByTestId('display-mode-option-url')).toBeInTheDocument()
})
it('persists override to localStorage when mode selected', () => {
it('persists override to vault config when mode selected', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -891,12 +884,13 @@ describe('DynamicPropertiesPanel', () => {
)
fireEvent.click(screen.getByTestId('display-mode-trigger'))
fireEvent.click(screen.getByTestId('display-mode-option-status'))
const stored = JSON.parse(localStorageMock.getItem('laputa:display-mode-overrides') ?? '{}')
const stored = getVaultConfig().property_display_modes as Record<string, string>
expect(stored).toBeTruthy()
expect(stored.cadence).toBe('status')
})
it('overrides rendering to status badge when status mode selected', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ cadence: 'status' }))
initDisplayModeOverrides({ cadence: 'status' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -909,7 +903,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders boolean toggle for string "true" when boolean mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' }))
initDisplayModeOverrides({ draft: 'boolean' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -923,7 +917,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders boolean toggle for string "false" when boolean mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' }))
initDisplayModeOverrides({ draft: 'boolean' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -937,7 +931,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('toggles string boolean from false to true', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' }))
initDisplayModeOverrides({ draft: 'boolean' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -951,7 +945,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders date picker for empty value when date mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ due: 'date' }))
initDisplayModeOverrides({ due: 'date' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -965,7 +959,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders date picker for non-date string when date mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ deadline: 'date' }))
initDisplayModeOverrides({ deadline: 'date' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}

View File

@@ -1,17 +1,18 @@
import { useMemo, useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { parseFrontmatter } from '../utils/frontmatter'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { usePropertyPanelState } from '../hooks/usePropertyPanelState'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag } from 'lucide-react'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { isValidCssColor } from '../utils/colorUtils'
import { getTypeIcon } from './NoteItem'
import { countWords } from '../utils/wikilinks'
import {
@@ -19,14 +20,12 @@ import {
getEffectiveDisplayMode,
formatDateValue,
toISODate,
loadDisplayModeOverrides,
saveDisplayModeOverride,
removeDisplayModeOverride,
detectPropertyType,
} from '../utils/propertyTypes'
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
import { ColorEditableValue } from './ColorInput'
// Keys that are relationships (contain wikilinks)
export const RELATIONSHIP_KEYS = new Set([
@@ -34,9 +33,6 @@ export const RELATIONSHIP_KEYS = new Set([
'Advances', 'Parent', 'Children', 'Has', 'Notes',
])
// Keys to skip showing in Properties (handled by dedicated UI or internal)
const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace', 'title', 'type', 'is_a', 'Is A'])
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function containsWikilinks(value: FrontmatterValue): boolean {
if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value)
@@ -51,19 +47,6 @@ function formatDate(timestamp: number | null): string {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
function coerceValue(raw: string): FrontmatterValue {
if (raw.toLowerCase() === 'true') return true
if (raw.toLowerCase() === 'false') return false
if (!isNaN(Number(raw)) && raw.trim() !== '') return Number(raw)
return raw
}
function parseNewValue(rawValue: string): FrontmatterValue {
if (!rawValue.includes(',')) return rawValue.trim() || ''
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
return items.length === 1 ? items[0] : items
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
@@ -253,6 +236,7 @@ const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
{ value: 'status', label: 'Status' },
{ value: 'url', label: 'URL' },
{ value: 'tags', label: 'Tags' },
{ value: 'color', label: 'Color' },
]
function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
@@ -331,7 +315,7 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
}
const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag,
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
}
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
@@ -565,6 +549,7 @@ function toBooleanValue(value: FrontmatterValue): boolean {
function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'string' && isUrlValue(value)) return 'url'
if (typeof value === 'string' && isValidCssColor(value) && value.startsWith('#')) return 'color'
return 'text'
}
@@ -591,6 +576,8 @@ function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses
}
case 'url':
return <UrlValue {...editProps} />
case 'color':
return <ColorEditableValue {...editProps} />
default:
return <EditableValue {...editProps} />
}
@@ -670,134 +657,6 @@ function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: n
)
}
function reconcileListUpdate(
newItems: string[],
onUpdate: (key: string, value: FrontmatterValue) => void,
onDelete: ((key: string) => void) | undefined,
key: string,
) {
if (newItems.length === 0) onDelete?.(key)
else if (newItems.length === 1) onUpdate(key, newItems[0])
else onUpdate(key, newItems)
}
function deriveTypeInfo(entries: VaultEntry[] | undefined, entryIsA: string | null) {
const typeEntries = (entries ?? []).filter(e => e.isA === 'Type')
const typeColorKeys: Record<string, string | null> = {}
const typeIconKeys: Record<string, string | null> = {}
for (const e of typeEntries) {
typeColorKeys[e.title] = e.color ?? null
typeIconKeys[e.title] = e.icon ?? null
}
return {
availableTypes: typeEntries.map(e => e.title).sort((a, b) => a.localeCompare(b)),
customColorKey: entryIsA ? (typeColorKeys[entryIsA] ?? null) : null,
typeColorKeys,
typeIconKeys,
}
}
function collectVaultStatuses(entries: VaultEntry[] | undefined): string[] {
const seen = new Set<string>()
for (const e of entries ?? []) {
if (e.status) seen.add(e.status)
}
return Array.from(seen).sort((a, b) => a.localeCompare(b))
}
function mergeArrayFieldsInto(fm: ParsedFrontmatter, tagsByKey: Map<string, Set<string>>): void {
for (const [key, value] of Object.entries(fm)) {
if (!Array.isArray(value)) continue
let set = tagsByKey.get(key)
if (!set) { set = new Set(); tagsByKey.set(key, set) }
for (const tag of value) set.add(String(tag))
}
}
function collectAllVaultTags(entries: VaultEntry[] | undefined, allContent: Record<string, string> | undefined): Record<string, string[]> {
if (!entries || !allContent) return {}
const tagsByKey = new Map<string, Set<string>>()
for (const entry of entries) {
const content = allContent[entry.path]
if (!content) continue
mergeArrayFieldsInto(parseFrontmatter(content), tagsByKey)
}
const result: Record<string, string[]> = {}
for (const [key, set] of tagsByKey) result[key] = Array.from(set).sort((a, b) => a.localeCompare(b))
return result
}
function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
return !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value)
}
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
if (mode === 'boolean') return rawValue.toLowerCase() === 'true'
if (mode === 'tags') {
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
return items
}
return parseNewValue(rawValue)
}
function persistModeOverride(key: string, mode: PropertyDisplayMode | null) {
if (mode === null) removeDisplayModeOverride(key)
else saveDisplayModeOverride(key, mode)
}
interface PropertyPanelDeps {
entries: VaultEntry[] | undefined
entryIsA: string | null
frontmatter: ParsedFrontmatter
allContent: Record<string, string> | undefined
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
}
function usePropertyPanelState(deps: PropertyPanelDeps) {
const { entries, entryIsA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
const [editingKey, setEditingKey] = useState<string | null>(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides())
const { availableTypes, customColorKey, typeColorKeys, typeIconKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries, allContent), [entries, allContent])
const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter])
const handleSaveValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
if (onUpdateProperty) onUpdateProperty(key, coerceValue(newValue))
}, [onUpdateProperty])
const handleSaveList = useCallback((key: string, newItems: string[]) => {
if (!onUpdateProperty) return
reconcileListUpdate(newItems, onUpdateProperty, onDeleteProperty, key)
}, [onUpdateProperty, onDeleteProperty])
const handleAdd = useCallback((rawKey: string, rawValue: string, mode: PropertyDisplayMode) => {
if (!rawKey.trim() || !onAddProperty) return
onAddProperty(rawKey.trim(), parseAddedValue(rawValue, mode))
if (mode !== 'text') {
persistModeOverride(rawKey.trim(), mode)
setDisplayOverrides(loadDisplayModeOverrides())
}
setShowAddDialog(false)
}, [onAddProperty])
const handleDisplayModeChange = useCallback((key: string, mode: PropertyDisplayMode | null) => {
persistModeOverride(key, mode)
setDisplayOverrides(loadDisplayModeOverrides())
}, [])
return {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
}
}
export function DynamicPropertiesPanel({
entry, content, frontmatter, entries, allContent,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,

View File

@@ -75,7 +75,7 @@ const mockEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -328,6 +328,64 @@ describe('Editor', () => {
fireEvent.click(screen.getByTestId('trashed-banner-delete'))
expect(onDeleteNote).toHaveBeenCalledWith(trashedEntry.path)
})
it('shows trash banner immediately when entry changes to trashed (reactive)', () => {
const { rerender } = render(
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
const updatedTab = { entry: { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
})
it('removes trash banner immediately when entry is restored (reactive)', () => {
const { rerender } = render(
<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
const restoredTab = { entry: { ...trashedEntry, trashed: false, trashedAt: null }, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
})
})
})
describe('archived note behavior', () => {
it('shows archive banner immediately when entry changes to archived (reactive)', () => {
const { rerender } = render(
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
const archivedTab = { entry: { ...mockEntry, archived: true }, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
})
it('removes archive banner immediately when entry is unarchived (reactive)', () => {
const archivedEntry: VaultEntry = { ...mockEntry, archived: true }
const archivedTab = { entry: archivedEntry, content: mockContent }
const { rerender } = render(
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
const unarchivedTab = { entry: { ...archivedEntry, archived: false }, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
})
})

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