Compare commits

...

60 Commits

Author SHA1 Message Date
Test
bcfd37d481 fix: CodeMirror cursor placement at non-100% zoom levels
Root cause: CSS zoom on document.documentElement caused stale CodeMirror
measurements. Two issues:

1. Race condition: zoom was applied in useEffect (parent), but CodeMirror
   was created in useEffect (child) — child effects run first, so CM
   measured at zoom=1 before zoom was actually applied.

2. No re-measure on zoom change: CSS zoom changes don't trigger
   ResizeObserver on descendant elements, so CodeMirror never updated
   its cached scaleX/scaleY, line heights, or character widths.

Fix:
- Apply zoom synchronously during useState init (before child effects)
- Dispatch 'laputa-zoom-change' event when zoom changes
- Listen for this event in useCodeMirror and call view.requestMeasure()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 14:54:50 +01:00
Test
f27ebe05c4 fix: pass active note content directly to AI context builder
In Tauri mode, allContent is {} (empty) until a note is explicitly
saved. The previous mergeTabContent fix enriched allContent with tab
content, but the indirection was fragile. This fix passes the active
tab's content directly to buildContextSnapshot as activeNoteContent,
which takes priority over allContent[path]. This ensures the AI always
receives the note body regardless of allContent state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:42:09 +01:00
Test
7470e4f4a7 fix: embed conversation history in prompt instead of broken --resume
Each CLI invocation is a fresh subprocess — --resume depends on session
persistence that doesn't reliably work with -p mode. Switch to prompt-
embedded history: prior exchanges are formatted into each request using
formatMessageWithHistory + trimHistory (already existed as dead code).

- Remove sessionIdRef and --resume session tracking
- Always send system prompt (each request is stateless)
- Split sendMessage into doSend(text, history) to handle retry correctly
- Retry now passes correct history (excludes the retried exchange)
- Tests verify history accumulation, clear, retry, and system prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:06:58 +01:00
Test
0e503cb179 fix: AI chat receives note body from open tabs instead of empty allContent
allContent is empty ({}) in Tauri mode because loadVaultData never
populates it — note content only enters allContent on explicit save.
mergeTabContent() enriches allContent with open tab content so the AI
context snapshot always includes the active note's body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:32:52 +01:00
Test
d83f04c6ff fix: AI-created notes now visible — onVaultChanged fallback for missed file ops
detectFileOperation silently failed when tool input was undefined (timing
issues with NDJSON event ordering). Added onVaultChanged callback as fallback:
when Write/Edit/Bash tools complete but specific file can't be determined,
vault.reloadVault() is triggered. Also added safety net in onDone handler.

Threaded onVaultChanged through AiPanel → EditorRightPanel → Editor → App.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 14:19:41 +01:00
Test
9ffa6930c5 fix: AI chat wikilinks — system prompt + integration tests
Root cause: buildContextSnapshot (the system prompt used when a note is
active — the common case) did NOT instruct the AI to use [[wikilinks]].
Only buildAgentSystemPrompt (the fallback when no context) had it.
So the AI almost never produced clickable wikilinks.

Fix: Add the [[Note Title]] wikilink instruction to
buildContextSnapshot's preamble.

The click handler chain was already correct (verified with new
integration tests): MarkdownContent → AiMessage → AiPanel →
notes.handleNavigateWikilink → findWikilinkTarget → handleSelectNote.

Tests added:
- Unit: buildContextSnapshot includes wikilink instruction
- Integration: clicking wikilinks in AiPanel calls onOpenNote
- Playwright: wikilink renders, click opens note in tab

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 13:42:32 +01:00
Test
88b20b83dc fix: use --resume for AI chat conversation continuity
The previous approach embedded conversation history as formatted text
in the prompt (<conversation_history> tags). The claude CLI's -p flag
treats this as a single user turn, losing turn boundaries — so the
model had no real multi-turn context.

Switch to using --resume with the session_id returned by the CLI's
init event. This lets the CLI manage conversation state natively:
- First message starts a new session (with system prompt)
- Subsequent messages resume via --resume (no system prompt needed)
- Clear and retry reset the session_id for a fresh start

Extract makeStreamCallbacks() to keep useAIChat complexity below
CodeScene threshold.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 13:11:34 +01:00
Test
548e5694ac style: cargo fmt config_seed.rs and getting_started.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:46:43 +01:00
Test
0cf8f55a8d fix: clippy doc_lazy_continuation in config_seed.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:45:09 +01:00
Test
72b88cef43 docs: add config/ vault type to architecture and abstractions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:42:56 +01:00
Test
8db9f61d5c feat: add Repair Vault command and MCP configFiles
- Add "Repair Vault" to command palette (Cmd+K → "Repair Vault")
- Add "Repair Vault" to macOS Vault menu bar
- Wire repair_vault Tauri command through App → useAppCommands → registry
- Add menu event handler for vault-repair
- Update MCP get_vault_context to include configFiles.agents content
- Add repair_vault mock handler for browser testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:39:55 +01:00
Test
fb2067ec79 feat: add config/ vault type with agents.md migration
- Add config_seed module: seed_config_files, migrate_agents_md, repair_config_files
- New vaults seed config/agents.md + root AGENTS.md stub (Codex compat)
- Existing vaults auto-migrate root AGENTS.md → config/agents.md on open
- Add type/config.md (icon: gear-six, sidebar label: Config)
- Add "config" → "Config" folder type mapping
- Add repair_vault Tauri command (themes + config files)
- 24 new tests covering seeding, migration, repair, idempotency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:35:52 +01:00
Test
b60bdb685d fix: MCP install command always visible in Cmd+K regardless of mcpStatus
The command was gated on `mcpStatus !== 'checking'` which meant it was
hidden during the initial async status check. Changed enabled to always
be true so users can find and run the command immediately on app start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:58:46 +01:00
Test
83009d8fb9 feat: make MCP restore command always available in Cmd+K
The "Install MCP Server" command was only enabled when status was
"not_installed", preventing users from re-registering when MCP got
removed or broken. Now the command is always available:
- Shows "Install MCP Server" when not installed
- Shows "Restore MCP Server" when already installed
- Added restore/fix/repair keywords for discoverability
- Context-aware toast: "installed" vs "restored"
- Menu bar label updated to "Restore MCP Server"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:33:18 +01:00
Test
068d434344 fix: auto-save structural UI changes to disk immediately
Structural changes (sidebar visibility, label, order, template, icon/color)
now auto-create missing Type entries and call onFrontmatterPersisted to
refresh git status, so changes appear in git diff without user intervention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:06:10 +01:00
Test
6b2a1b0659 fix: scope qmd update/embed to current vault collection only
Previously called 'qmd update' and 'qmd embed' without arguments, which
updated all global qmd collections. If any other collection had a broken
path, the entire indexing would fail.

Now passes the vault name explicitly:
  qmd update <vault_name>
  qmd embed -c <vault_name>

This makes reindex independent of other qmd collections on the system.
2026-03-07 10:08:39 +01:00
Test
bf5f5521af fix: wikilink clicks in AI chat now open note in tab
AiPanel.handleNavigateWikilink was double-resolving: it resolved the
wikilink target to an entry path, then passed the path to onOpenNote
which is notes.handleNavigateWikilink (expects a title-based target).
The path didn't match any entry's title, so navigation silently failed.

Fix: pass the wikilink target string directly to onOpenNote, letting
the parent's handleNavigateWikilink do the resolution.

Also enhanced the Playwright smoke test to verify click → tab opens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:14:34 +01:00
Test
f4961d0bc3 fix: add missing ws dependency for smoke tests
The ai-notes-visibility-fix smoke test imports 'ws' (WebSocketServer)
but it wasn't listed as a dev dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:10:50 +01:00
Test
058de96cbc docs: update ARCHITECTURE, ABSTRACTIONS, GETTING-STARTED to reflect current codebase
Updated all three docs to reflect significant features added since they were last written:
- AI agent panel (Claude CLI subprocess with tool execution + NDJSON streaming)
- Vault cache system (git-based incremental caching in cache.rs)
- Theme system (vault-based themes, useThemeManager, ThemePropertyEditor)
- Search & indexing (qmd integration, keyword/semantic/hybrid modes)
- Pulse view (git activity feed with pagination)
- GitHub OAuth (device flow, vault clone/create)
- Vault management (multi-vault, vault config, onboarding, WelcomeScreen)
- Raw editor mode (CodeMirror 6 alternative)
- Command palette (Cmd+K registry)
- Auto-sync & conflict resolution

Also added mandatory docs-update rule to CLAUDE.md: docs/ files must be
updated in the same commit as significant feature changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:09:23 +01:00
Test
d29f919182 test: add Playwright smoke test for AI note visibility and tab opening
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:56:37 +01:00
Test
42e37e035c test: add unit tests for detectFileOperation and parseBashFileCreation
24 tests covering Write/Edit/Bash file detection, edge cases
(malformed JSON, files outside vault, non-md files, undefined input),
and the parseBashFileCreation helper for redirect/tee patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:41:06 +01:00
Test
0ad0fa9b6b fix: AI-created notes now trigger vault refresh and auto-open in tab
Root causes:
- toolInputMapRef in useAiAgent was overwritten by tool_progress events
  (which arrive with input=undefined AFTER the assistant message set
  the full input), causing detectFileOperation to receive undefined
  and skip file creation detection entirely.
- MCP open_note only broadcast open_tab without vault_changed, so
  the note list didn't refresh when Claude Code called open_note.
- detectFileOperation only handled Write/Edit but not Bash commands
  that create .md files via redirects.

Fixes:
- Preserve accumulated input in toolInputMapRef (input ?? prev?.input)
- MCP open_note now broadcasts vault_changed before open_tab
- detectFileOperation now detects Bash redirect patterns (>, >>, tee)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:39:34 +01:00
Test
c37c03d6a9 fix: remove --resume from AI chat to fix conversation history
The AI chat was using both --resume (CLI session resumption) AND formatted
conversation history in the prompt simultaneously. This dual-context approach
confused the model — it saw the conversation twice (from session + from prompt
markup), leading to "I don't have context" responses on follow-ups.

Fix: remove --resume entirely from chat mode. Each CLI call is now independent,
with full conversation history formatted into the prompt via
<conversation_history> markup. trimHistory handles graceful truncation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 03:08:24 +01:00
Test
fcc264d7dc feat: restore MCP UI-steering tools (highlight_editor, refresh_vault)
Add highlight_editor and refresh_vault tools to the MCP stdio server
so Claude Code can visually highlight UI elements and trigger vault
rescans. Also fix outdated test.js imports after the ai-agent-full-shell
simplification removed write operations from vault.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 02:03:21 +01:00
Test
382ba0a6d4 test: add Playwright smoke test for wikilink rendering in AI chat
Update mock agent response to include [[wikilinks]] for testing.
Add smoke test verifying wikilinks render as clickable elements
with correct text, attributes, and styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:58:08 +01:00
Test
4719810b10 feat: render [[wikilinks]] as clickable links in AI chat
- System prompts instruct AI to use [[Note Title]] wikilink syntax
- preprocessWikilinks converts [[Target]] to markdown links
- Custom urlTransform allows wikilink:// scheme through sanitizer
- Click handler resolves target via findEntryByTarget and opens note
- Styled as colored chips matching primary accent
- Works in both AiPanel (agent) and AIChatPanel (legacy chat)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:40:44 +01:00
Test
20b4ba7a3b fix: clippy errors — reduce visibility of internal functions, fix PI approx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:19:45 +01:00
Test
de00ab6794 feat: reindex vault command, indexing status bar, sync-triggered reindex
- Add "Reindex Vault" command to command palette and menu bar
- Show "Indexed Xm ago" in status bar when idle, clickable to reindex
- After git pull with updates, auto-trigger incremental reindex
- Add lastIndexedTime state to useIndexing, populated from backend metadata
- Add triggerFullReindex to useIndexing (retryIndexing is now an alias)
- Add onSyncUpdated callback to useAutoSync
- Extract formatIndexedElapsed to utils/indexingHelpers.ts
- Tests: 20 new unit tests across 5 files, 2 Playwright smoke tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:14:12 +01:00
Test
5d8f514bea feat: add last_indexed_commit persistence to indexing backend
Store last_indexed_commit and last_indexed_at in .laputa-index.json
after every successful full or incremental index. Include these in
IndexStatus so the frontend can display staleness. Add
needs_reindex_after_sync() helper that compares HEAD vs stored commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:00:58 +01:00
Test
1fd3ea02ae fix: rustfmt import formatting in commands.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:38:08 +01:00
Test
8da1484ebf test: Playwright smoke test for push error UX
Expose mockHandlers on window for Playwright overrides. Test that
rejected push shows "Pull first" message, auth error shows
"authentication error", and success shows "Committed and pushed".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:35:35 +01:00
Test
90ebc2e939 feat: surface actionable push error messages in frontend
Update commitWithPush to parse GitPushResult from backend and show
specific messages (rejected, auth, network) instead of generic
"push failed". Tests verify rejected + network error scenarios.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:31:46 +01:00
Test
c14927df8f feat: add GitPushResult with error classification for push failures
Replaces raw string return from git_push with a structured GitPushResult
that classifies errors as rejected/auth_error/network_error/error, each
with an actionable user-facing message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:29:54 +01:00
Test
a6d60695a2 style: rustfmt formatting fix in cache test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:22:33 +01:00
Test
7ddc0c14bf fix: add visible key to frontmatterToEntryPatch maps
The ENTRY_DELETE_MAP and update map in frontmatterToEntryPatch were
missing the 'visible' key. When handleDeleteProperty or
handleUpdateFrontmatter was called for 'visible', the in-memory
VaultEntry was not updated, causing the sidebar to stay stale even
after the file on disk was correctly modified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:20:51 +01:00
Test
2a00b8aac7 fix: include full conversation history in AI chat requests
- Add trimHistory() to keep most recent messages within 100k token budget
- Add formatMessageWithHistory() to prepend conversation context to each message
- Wire up in useAIChat.ts: sendMessage now includes full history
- Keep --resume as belt-and-suspenders for same-session continuity
- 14 new tests in ai-chat.test.ts and useAIChat.test.ts
2026-03-06 23:47:59 +01:00
Test
b7d2304282 test: fix Sidebar tests after Favorites removal 2026-03-06 23:22:14 +01:00
Test
50b5fa9c2e refactor: remove Favorites and Untagged from sidebar
- Remove Favorites NavItem, Star icon import, go-favorites command
- Remove Untagged NavItem, TagSimple icon import
- Remove favorites from SidebarFilter union type
- Update tests: Sidebar.test.tsx, useMenuEvents.test.ts, App.test.tsx
- Remove GO_FAVORITES constant and menu item from menu.rs
2026-03-06 23:19:37 +01:00
Test
963e7cf111 refactor: rustfmt formatting fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:15:24 +01:00
Test
c9a5d20c12 test: Playwright smoke test for trash → Changes badge
Also track mock frontmatter writes in mockSavedSinceCommit so the
Changes panel updates correctly in browser dev mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:08:18 +01:00
Test
586e1fcde5 fix: refresh Changes panel after trash/archive operations
Trash, archive, restore, and unarchive wrote frontmatter to disk but
never called loadModifiedFiles, so the change didn't appear in the
Changes panel until the next manual refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:01:05 +01:00
Test
75d67623ce Pulse: fix slow note open — O(1) map lookup, no reloadVault on click
Root cause: clicking a note in Pulse used an inline arrow function that:
1. Was recreated on every render (new prop ref → PulseView memo bypassed)
2. Called vault.reloadVault() (full 9000-note rescan) when path didn't match

Fix:
- Add entriesByPath Map (useMemo) — O(1) lookups instead of O(n) .find()
- Add handlePulseOpenNote (useCallback) — stable ref, never triggers reloadVault
  (Pulse notes always exist in vault; no reload needed)
- Wire PulseView to handlePulseOpenNote instead of inline arrow
- Also use entriesByPath in openNoteByPath (MCP bridge)
2026-03-06 22:25:55 +01:00
Test
b9d94abae4 style: rustfmt vault_config.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:58:39 +01:00
Test
18b2aaedf6 fix: add missing visible field to buildNewEntry in useNoteActions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:57:00 +01:00
Test
1706300494 test: add Playwright smoke test for visible type property
Also updates vite.config.ts vault API parser to include all VaultEntry
fields (visible, icon, color, order, etc.) so the dev server returns
complete entries for sidebar filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:52:40 +01:00
Test
628ab76f09 feat: migrate hidden_sections from ui.config to visible property on Type notes
On startup, reads hidden_sections from config/ui.config.md, creates or
updates Type notes with visible: false, then re-saves config without
hidden_sections. Idempotent and safe to run multiple times.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:44:30 +01:00
Test
d3896ddf01 refactor: remove hidden_sections from VaultConfig and delete useSectionVisibility
Sidebar section visibility is now controlled entirely by the `visible`
property on Type notes. Removes all hidden_sections references from
Rust struct, TypeScript interface, config migration, mock data, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:41:00 +01:00
Test
7289a60db3 feat: add handleToggleTypeVisibility to useEntryActions
Toggle visible property on Type note frontmatter: sets visible:false
to hide, deletes visible property to show (defaults to visible).
Wire up in App.tsx via onToggleTypeVisibility prop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:35:18 +01:00
Test
0cff626e48 feat: filter sidebar sections by Type entry visible property
Replace useSectionVisibility hook with direct filtering on
typeEntryMap[type]?.visible !== false. Add onToggleTypeVisibility
callback prop. Tests updated to verify visible:false hides sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:33:47 +01:00
Test
5a4c986fe3 feat: add visible field to VaultEntry for Type note sidebar visibility
Parse `visible` boolean from Type note frontmatter (defaults to None/true
when absent). Add to SKIP_KEYS to exclude from relationships and properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:30:18 +01:00
Test
540b1400e2 🐛 Auto-untrack .laputa-cache.json and .laputa/settings.json from git
These are machine-local files that should never be version controlled:
- .laputa-cache.json: contains absolute paths, changes on every machine
- .laputa/settings.json: per-machine UI settings

Fix: ensure_cache_excluded() now:
1. Adds both files to .git/info/exclude (git-level ignore, no .gitignore needed)
2. Runs `git rm --cached --ignore-unmatch` on vault open to un-track them
   if they were committed in older vaults

This is idempotent and self-healing — existing vaults fix themselves
automatically on next app launch without any manual steps.
2026-03-06 21:10:37 +01:00
Test
826cda852a Pulse: lazy pagination with IntersectionObserver infinite scroll
- Page size reduced from 30 → 20 commits per fetch
- Backend: add `skip` param to get_vault_pulse (git log --skip)
  → true pagination instead of re-fetching everything
- Frontend: IntersectionObserver sentinel at bottom of feed
  → auto-loads next page when user scrolls near end
- Append-only updates (no full re-render on load more)
- Add IntersectionObserver mock to test setup
2026-03-06 21:04:24 +01:00
Test
19583ea1f5 💅 Pulse: add right border, collapse commit files by default
- Add border-r border-[var(--sidebar-border)] to PulseView container
- CommitCard files default to collapsed (expanded: false) for cleaner initial state
- Update tests to reflect new collapsed-by-default behavior
2026-03-06 21:00:35 +01:00
Test
63eb4ff980 fix: address clippy lints in title_case_folder
Use char array pattern and function reference instead of closures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:11:57 +01:00
Test
bbb29857b8 test: add regression tests for hyphenated folder sidebar duplicates
- Sidebar unit test: entries with isA 'Monday Ideas' produce exactly
  one section header (not two)
- Playwright smoke test: verify no duplicate section labels in sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:09:29 +01:00
Test
900ce7f66f fix: normalize hyphenated folder names in infer_type_from_folder()
Split on hyphens and underscores, capitalize each word, join with
spaces. E.g. monday-ideas → Monday Ideas, key-result → Key Result.
This prevents duplicate sidebar sections when folder name differs
from the Type file title.

Bump CACHE_VERSION to 5 to force rescan on existing caches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:59:51 +01:00
Test
eb55c5ec02 refactor: apply rustfmt formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:43:43 +01:00
Test
6f6e7d7cfe fix: vault cache misses files in new directories, breaking theme restore
Root cause: git_uncommitted_files() used `git status --porcelain` which
reports new untracked directories as `?? theme/` instead of listing
individual files inside. The .md filter skipped directory entries, so
files in newly-created directories (theme/default.md, etc.) were
invisible to the cache system.

Fix: additionally use `git ls-files --others --exclude-standard` which
lists individual untracked files, resolving directories to their
contents.

Also:
- Add type/theme.md definition (icon: palette) to restore and getting-started vault
- Seed theme/*.md vault notes in getting-started vault
- Fix mock create_vault_theme to add entries for dev mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:38:54 +01:00
Test
edcb306c7f fix: exclude .laputa-cache.json and settings.json from vault git tracking
These files are machine-specific and should never be committed or cause
conflicts when syncing vaults across devices.

Changes:
- init_repo() now writes .gitignore with .laputa-cache.json and
  .laputa/settings.json excluded before the first commit
- .DS_Store and common editor artifacts also excluded
- openConflictFileRef falls back to openLocalFile() for non-note files
  so 'Open in editor' works for .json conflict files (opens in system
  default app, e.g. TextEdit/VS Code)
- Removed stale git.rs (replaced by git/mod.rs from refactor)
- 2 new Rust tests for .gitignore creation behavior
2026-03-06 15:22:48 +01:00
Test
97be1d1ca3 fix: write .gitignore on vault init to exclude .DS_Store
macOS creates .DS_Store files in every folder which were being tracked
by git, causing constant conflicts when vaults are synced across machines.

init_repo() now writes a .gitignore before the first commit with:
- .DS_Store, .AppleDouble, .LSOverride
- ._ thumbnail files
- Common editor artifacts (.vscode/, .idea/, *.swp)

The file is only written if one doesn't already exist, so user-customized
.gitignore files are respected.

Two new Rust tests verify the behavior.
2026-03-06 15:19:25 +01:00
116 changed files with 5560 additions and 1066 deletions

View File

@@ -1 +1 @@
33549
91305

View File

@@ -115,7 +115,29 @@ 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
- **Update docs/** when changing architecture, abstractions, or significant design (mandatory — see rule below)
## ⛔ DOCS — Keep docs/ in sync with code (mandatory)
After any significant feature change, update the relevant `docs/` files **in the same commit**:
- **`docs/ARCHITECTURE.md`** — stack, system overview, component structure, Tauri commands, data flow, backend modules
- **`docs/ABSTRACTIONS.md`** — domain models, VaultEntry fields, entity types, key abstractions, integration patterns
- **`docs/GETTING-STARTED.md`** — directory structure, key files, common tasks, test commands, onboarding
**What counts as "significant":**
- Adding a new Tauri command or backend module
- Adding a new major component, hook, or feature (not a bugfix)
- Changing the data model (VaultEntry fields, new types, new config files)
- Adding a new integration (API, service, transport)
- Changing the architecture (new panels, new state management, new build steps)
**How to update:**
1. Read the relevant doc section before making changes
2. After your code changes, update the doc to reflect the new state
3. Commit doc changes together with the code — not in a separate follow-up commit
If unsure whether a change is "significant", err on the side of updating. Stale docs are worse than slightly verbose docs.
## TDD — Red/Green/Refactor (mandatory)

View File

@@ -0,0 +1,3 @@
{
"theme": null
}

View File

@@ -0,0 +1,33 @@
{
"name": "Dark",
"description": "Dark variant with deep navy tones",
"colors": {
"background": "#0f0f1a",
"foreground": "#e0e0e0",
"card": "#16162a",
"popover": "#1e1e3a",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#2a2a4a",
"secondary-foreground": "#e0e0e0",
"muted": "#1e1e3a",
"muted-foreground": "#888888",
"accent": "#2a2a4a",
"accent-foreground": "#e0e0e0",
"destructive": "#f44336",
"border": "#2a2a4a",
"input": "#2a2a4a",
"ring": "#155DFF",
"sidebar-background": "#1a1a2e",
"sidebar-foreground": "#e0e0e0",
"sidebar-border": "#2a2a4a",
"sidebar-accent": "#2a2a4a"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}

View File

@@ -0,0 +1,33 @@
{
"name": "Default",
"description": "Light theme with warm, paper-like tones",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"destructive": "#E03E3E",
"border": "#E9E9E7",
"input": "#E9E9E7",
"ring": "#155DFF",
"sidebar-background": "#F7F6F3",
"sidebar-foreground": "#37352F",
"sidebar-border": "#E9E9E7",
"sidebar-accent": "#EBEBEA"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}

View File

@@ -0,0 +1,33 @@
{
"name": "Minimal",
"description": "High contrast, minimal chrome",
"colors": {
"background": "#FAFAFA",
"foreground": "#111111",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#000000",
"primary-foreground": "#FFFFFF",
"secondary": "#F0F0F0",
"secondary-foreground": "#111111",
"muted": "#F5F5F5",
"muted-foreground": "#666666",
"accent": "#F0F0F0",
"accent-foreground": "#111111",
"destructive": "#CC0000",
"border": "#E0E0E0",
"input": "#E0E0E0",
"ring": "#000000",
"sidebar-background": "#F5F5F5",
"sidebar-foreground": "#111111",
"sidebar-border": "#E0E0E0",
"sidebar-accent": "#E8E8E8"
},
"typography": {
"font-family": "'SF Mono', 'Menlo', monospace",
"font-size-base": "13px"
},
"spacing": {
"sidebar-width": "220px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,5 @@
---
type: config
zoom: 1.3
view_mode: all
---

View File

@@ -4,6 +4,8 @@ Is A: Note
Author: "Clayton Christensen"
Topics: ["[[topic-saas-business]]"]
URL: "https://example.com/innovators-dilemma"
trashed: true
trashed_at: 2026-03-04
---
# The Innovator's Dilemma
*Clayton Christensen*

View File

@@ -0,0 +1,5 @@
# Wikilinks QA Test
See [[ProjectX]] and [[Team Goals|Goals]] for details on the project timeline.
This is a test note for QA purposes.

View File

@@ -0,0 +1,54 @@
---
Is A: Theme
Description: Dark variant with deep navy tones
background: "#0f0f1a"
foreground: "#e0e0e0"
card: "#16162a"
popover: "#1e1e3a"
primary: "#155DFF"
primary-foreground: "#FFFFFF"
secondary: "#2a2a4a"
secondary-foreground: "#e0e0e0"
muted: "#1e1e3a"
muted-foreground: "#888888"
accent: "#2a2a4a"
accent-foreground: "#e0e0e0"
destructive: "#f44336"
border: "#2a2a4a"
input: "#2a2a4a"
ring: "#155DFF"
sidebar: "#1a1a2e"
sidebar-foreground: "#e0e0e0"
sidebar-border: "#2a2a4a"
sidebar-accent: "#2a2a4a"
text-primary: "#e0e0e0"
text-secondary: "#888888"
text-muted: "#666666"
text-heading: "#e0e0e0"
bg-primary: "#0f0f1a"
bg-sidebar: "#1a1a2e"
bg-hover: "#2a2a4a"
bg-hover-subtle: "#1e1e3a"
bg-selected: "#155DFF22"
border-primary: "#2a2a4a"
accent-blue: "#155DFF"
accent-green: "#00B38B"
accent-orange: "#D9730D"
accent-red: "#f44336"
accent-purple: "#A932FF"
accent-yellow: "#F0B100"
accent-blue-light: "#155DFF33"
accent-green-light: "#00B38B33"
accent-purple-light: "#A932FF33"
accent-red-light: "#f4433633"
accent-yellow-light: "#F0B10033"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14px
editor-font-size: 16
editor-line-height: 1.5
editor-max-width: 720
---
# Dark Theme
A dark theme with deep navy tones for comfortable night-time reading.

View File

@@ -0,0 +1,54 @@
---
Is A: Theme
Description: Light theme with warm, paper-like tones
background: "#FFFFFF"
foreground: "#37352F"
card: "#FFFFFF"
popover: "#FFFFFF"
primary: "#155DFF"
primary-foreground: "#FFFFFF"
secondary: "#EBEBEA"
secondary-foreground: "#37352F"
muted: "#F0F0EF"
muted-foreground: "#787774"
accent: "#EBEBEA"
accent-foreground: "#37352F"
destructive: "#E03E3E"
border: "#E9E9E7"
input: "#E9E9E7"
ring: "#155DFF"
sidebar: "#F7F6F3"
sidebar-foreground: "#37352F"
sidebar-border: "#E9E9E7"
sidebar-accent: "#EBEBEA"
text-primary: "#37352F"
text-secondary: "#787774"
text-muted: "#B4B4B4"
text-heading: "#37352F"
bg-primary: "#FFFFFF"
bg-sidebar: "#F7F6F3"
bg-hover: "#EBEBEA"
bg-hover-subtle: "#F0F0EF"
bg-selected: "#E8F4FE"
border-primary: "#E9E9E7"
accent-blue: "#155DFF"
accent-green: "#00B38B"
accent-orange: "#D9730D"
accent-red: "#E03E3E"
accent-purple: "#A932FF"
accent-yellow: "#F0B100"
accent-blue-light: "#155DFF14"
accent-green-light: "#00B38B14"
accent-purple-light: "#A932FF14"
accent-red-light: "#E03E3E14"
accent-yellow-light: "#F0B10014"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14px
editor-font-size: 16
editor-line-height: 1.5
editor-max-width: 720
---
# Default Theme
The default light theme for Laputa. Clean and warm, inspired by Notion.

View File

@@ -0,0 +1,54 @@
---
Is A: Theme
Description: High contrast, minimal chrome
background: "#FAFAFA"
foreground: "#111111"
card: "#FFFFFF"
popover: "#FFFFFF"
primary: "#000000"
primary-foreground: "#FFFFFF"
secondary: "#F0F0F0"
secondary-foreground: "#111111"
muted: "#F5F5F5"
muted-foreground: "#666666"
accent: "#F0F0F0"
accent-foreground: "#111111"
destructive: "#CC0000"
border: "#E0E0E0"
input: "#E0E0E0"
ring: "#000000"
sidebar: "#F5F5F5"
sidebar-foreground: "#111111"
sidebar-border: "#E0E0E0"
sidebar-accent: "#E8E8E8"
text-primary: "#111111"
text-secondary: "#666666"
text-muted: "#999999"
text-heading: "#111111"
bg-primary: "#FAFAFA"
bg-sidebar: "#F5F5F5"
bg-hover: "#EBEBEB"
bg-hover-subtle: "#F5F5F5"
bg-selected: "#00000014"
border-primary: "#E0E0E0"
accent-blue: "#000000"
accent-green: "#006600"
accent-orange: "#996600"
accent-red: "#CC0000"
accent-purple: "#660099"
accent-yellow: "#996600"
accent-blue-light: "#00000014"
accent-green-light: "#00660014"
accent-purple-light: "#66009914"
accent-red-light: "#CC000014"
accent-yellow-light: "#99660014"
font-family: "'SF Mono', 'Menlo', monospace"
font-size-base: 13px
editor-font-size: 15
editor-line-height: 1.6
editor-max-width: 680
---
# Minimal Theme
High contrast, minimal chrome. Monospace typography throughout.

View File

@@ -8,24 +8,32 @@ All data lives in markdown files with YAML frontmatter. There is no database —
### VaultEntry
The core data type representing a single note, defined identically in Rust (`src-tauri/src/vault.rs`) and TypeScript (`src/types.ts`):
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`):
```typescript
// src/types.ts
interface VaultEntry {
path: string // Absolute file path: /Users/luca/Laputa/project/my-project.md
filename: string // Just the filename: my-project.md
title: string // Extracted from first # heading, or filename as fallback
isA: string | null // Entity type: Project, Procedure, Person, etc.
aliases: string[] // Alternative names for wikilink resolution
belongsTo: string[] // Parent relationships (wikilinks)
relatedTo: string[] // Related entity links (wikilinks)
status: string | null // Active, Done, Paused, Archived, Dropped
owner: string | null // Person responsible
cadence: string | null // Update frequency: Weekly, Monthly, etc.
path: string // Absolute file path
filename: string // Just the filename
title: string // From first # heading, or filename fallback
isA: string | null // Entity type: Project, Procedure, Person, etc.
aliases: string[] // Alternative names for wikilink resolution
belongsTo: string[] // Parent relationships (wikilinks)
relatedTo: string[] // Related entity links (wikilinks)
relationships: Record<string, string[]> // All frontmatter fields containing wikilinks
outgoingLinks: string[] // All [[wikilinks]] found in note body
status: string | null // Active, Done, Paused, Archived, Dropped
owner: string | null // Person responsible
cadence: string | null // Update frequency: Weekly, Monthly, etc.
modifiedAt: number | null // Unix timestamp (seconds)
createdAt: number | null // Unix timestamp (seconds)
fileSize: number
wordCount: number | null // Body word count (excludes frontmatter)
snippet: string | null // First 200 chars of body
archived: boolean // Archived flag
trashed: boolean // Trashed flag
trashedAt: number | null // When trashed (for auto-purge)
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
}
```
@@ -47,26 +55,41 @@ Entity type is inferred from the folder structure. The vault is organized by typ
├── quarter/ → "Quarter"
├── journal/ → "Journal"
├── essay/ → "Essay"
── evergreen/ → "Evergreen"
── evergreen/ → "Evergreen"
├── theme/ → "Theme" ← vault-based themes
└── config/ → "Config" ← meta-configuration files (agents.md, etc.)
```
Mapping logic lives in `vault.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
Mapping logic lives in `vault/mod.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
### Types as Files
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
- Have `Is A: Type` in their frontmatter
- Describe what the type means, its expected properties, and how it relates to other types
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any other note
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
- Serve as the "definition" for their type category
**Type document properties** (read by Rust and used in the UI):
| Property | Type | Description |
|----------|------|-------------|
| `icon` | string | Phosphor icon name (kebab-case, e.g., "cooking-pot") |
| `color` | string | Accent color: red, purple, blue, green, yellow, orange |
| `order` | number | Sidebar display order (lower = higher priority) |
| `sidebar_label` | string | Custom label overriding auto-pluralization |
| `template` | string | Markdown template for new notes of this type |
| `sort` | string | Default sort: "modified:desc", "title:asc", "property:Priority:asc" |
| `view` | string | Default view mode: "all", "editor-list", "editor-only" |
| `visible` | bool | Whether type appears in sidebar (default: true) |
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.
**UI behavior**:
- Clicking a section group header (e.g., "Projects") pins the type document at the top of the NoteList if it exists, with instances listed below
- Clicking a section group header pins the type document at the top of the NoteList if it exists
- Viewing a type document in entity view shows an "Instances" group listing all entries of that type
- The Type field in the Inspector properties panel is rendered as a clickable chip that navigates to the type document
- The Type field in the Inspector is rendered as a clickable chip that navigates to the type document
### Frontmatter Format
@@ -88,24 +111,48 @@ aliases:
---
```
Supported value types (defined in `src-tauri/src/frontmatter.rs` as `FrontmatterValue`):
Supported value types (defined in `src-tauri/src/frontmatter/yaml.rs` as `FrontmatterValue`):
- **String**: `status: Active`
- **Number**: `priority: 5`
- **Bool**: `archived: true`
- **List**: Multi-line ` - item` or inline `[item1, item2]`
- **Null**: `owner:` (empty value)
### Custom Relationships
The Rust parser scans all frontmatter keys for fields containing `[[wikilinks]]`. Any non-standard field with wikilink values is captured in the `relationships` HashMap:
```yaml
---
Topics:
- "[[topic/writing]]"
- "[[topic/productivity]]"
Key People:
- "[[person/matteo-cellini]]"
---
```
Becomes: `relationships["Topics"] = ["[[topic/writing]]", "[[topic/productivity]]"]`
This enables arbitrary, extensible relationship types without code changes.
### Outgoing Links
All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex and stored in `outgoingLinks`. Used for backlink detection and relationship graphs.
### Title Extraction
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. This logic lives in `vault.rs:extract_title()`.
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. Logic in `vault/parsing.rs:extract_title()`.
### Sidebar Selection
Navigation state is modeled as a discriminated union:
```typescript
type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
type SidebarSelection =
| { kind: 'filter'; filter: 'all' | 'favorites' }
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string } // e.g. type: 'Project'
| { kind: 'entity'; entry: VaultEntry } // specific entity selected
| { kind: 'topic'; entry: VaultEntry } // topic selected
@@ -115,49 +162,60 @@ type SidebarSelection =
### Vault Scanning (Rust)
`vault::scan_vault(path)` in `src-tauri/src/vault.rs`:
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
1. Validates the path exists and is a directory
2. Uses `walkdir` to recursively traverse the directory (follows symlinks)
2. Uses `walkdir` to recursively traverse (follows symlinks)
3. Filters to `.md` files only
4. For each file, calls `parse_md_file()`:
- Reads file content with `fs::read_to_string()`
- Reads content with `fs::read_to_string()`
- Parses frontmatter with `gray_matter::Matter::<YAML>`
- Extracts title from first `#` heading
- Infers entity type from parent folder name
- Parses dates (`created_at`, `created_time`) as ISO 8601 to Unix timestamps
- Collects file metadata (size, modification time)
5. Sorts results by `modified_at` descending (newest first)
- Infers entity type from parent folder name (or explicit `Is A`/`type` frontmatter)
- Parses dates as ISO 8601 to Unix timestamps
- Extracts relationships, outgoing links, custom properties, word count, snippet
5. Sorts by `modified_at` descending
6. Skips unparseable files with a warning log
### Vault Caching
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
1. Reads `.laputa-cache.json` if it exists
2. Compares cache version, vault path, and git HEAD commit hash
3. If cache is valid and same commit → only re-parse uncommitted changed files
4. If different commit → use `git diff` to find changed files → selective re-parse
5. If no cache → full scan
6. Writes updated cache after every scan
### Frontmatter Manipulation (Rust)
`frontmatter::update_frontmatter_content()` in `src-tauri/src/frontmatter.rs` performs line-by-line YAML editing:
`frontmatter/ops.rs:update_frontmatter_content()` performs line-by-line YAML editing:
1. Finds the frontmatter block between `---` delimiters
2. Iterates through lines looking for the target key (handles quoted keys like `"Is A"`)
2. Iterates through lines looking for the target key
3. If found: replaces the value (consuming multi-line list items if present)
4. If not found: appends the new key-value at the end of the frontmatter
5. If no frontmatter exists: creates a new `---` block with the key-value
4. If not found: appends the new key-value at the end
5. If no frontmatter exists: creates a new `---` block
The `with_frontmatter()` helper wraps this in a read-transform-write cycle on the actual file.
### Content Loading
- **Tauri mode**: Content is loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
- **Browser mode**: All content is loaded at startup from `MOCK_CONTENT` in `mock-tauri.ts`
- **Tauri mode**: Content loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
- **Browser mode**: All content loaded at startup from mock data
- Content for backlink detection (`allContent`) is stored in memory as `Record<string, string>`
## Git Integration
Git operations live in `src-tauri/src/git.rs`. All operations shell out to the `git` CLI (not libgit2).
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2).
### Data Types
```typescript
interface GitCommit {
hash: string // Full SHA-1
shortHash: string // First 7 chars
hash: string
shortHash: string
message: string
author: string
date: number // Unix timestamp
@@ -168,32 +226,54 @@ interface ModifiedFile {
relativePath: string // Relative to vault root
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
}
interface PulseCommit {
hash: string
shortHash: string
message: string
date: number
githubUrl: string | null
files: PulseFile[]
added: number
modified: number
deleted: number
}
```
### Operations
| Operation | Git command | Notes |
|-----------|------------|-------|
| File history | `git log --format=%H\|%h\|%an\|%aI\|%s -n 20 -- <file>` | Last 20 commits for a file |
| Modified files | `git status --porcelain` | Filtered to `.md` files only |
| File diff | `git diff -- <file>`, fallback to `--cached`, then synthetic diff for untracked | Unified diff format |
| Commit | `git add -A && git commit -m "<message>"` | Stages all changes |
| Push | `git push` | Pushes to upstream of current branch |
| Module | Operation | Notes |
|--------|-----------|-------|
| `history.rs` | File history | `git log` — last 20 commits per file |
| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` |
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
| `commit.rs` | Commit | `git add -A && git commit -m "..."` |
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
| `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual |
| `pulse.rs` | Activity feed | `git log` with `--name-status` for file changes |
### Auto-Sync
`useAutoSync` hook handles automatic git sync:
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
- Pulls on interval, pushes after commits
- Detects merge conflicts → opens `ConflictResolverModal`
### Frontend Integration
- **Modified file badges**: Loaded at startup, shown in sidebar and breadcrumb bar
- **Diff view**: Loaded on-demand when user clicks the diff toggle in the breadcrumb bar
- **Git history**: Loaded when active tab changes, shown in Inspector panel
- **Commit dialog**: Triggered from sidebar, runs commit + push
- **Modified file badges**: Orange dots in sidebar and tab bar
- **Diff view**: Toggle in breadcrumb bar → shows unified diff
- **Git history**: Shown in Inspector panel for active note
- **Commit dialog**: Triggered from sidebar or Cmd+K
- **Pulse view**: Activity feed when Pulse filter is selected
## BlockNote Customization
The editor uses [BlockNote](https://www.blocknotejs.org/) (not CodeMirror 6) for rich text editing.
The editor uses [BlockNote](https://www.blocknotejs.org/) for rich text editing, with CodeMirror 6 available as a raw editing alternative.
### Custom Wikilink Inline Content
Defined in `src/components/Editor.tsx`:
Defined in `src/components/editorSchema.tsx`:
```typescript
const WikiLink = createReactInlineContentSpec(
@@ -202,69 +282,196 @@ const WikiLink = createReactInlineContentSpec(
propSchema: { target: { default: "" } },
content: "none",
},
{
render: (props) => (
<span className="wikilink" data-target={props.inlineContent.props.target}>
{props.inlineContent.props.target}
</span>
),
}
{ render: (props) => <span className="wikilink">...</span> }
)
const schema = BlockNoteSchema.create({
inlineContentSpecs: {
...defaultInlineContentSpecs,
wikilink: WikiLink,
},
})
```
### Markdown-to-BlockNote Pipeline
Since BlockNote doesn't natively understand `[[wikilinks]]`, content goes through a preprocessing pipeline in `src/utils/wikilinks.ts`:
```
Raw markdown
→ splitFrontmatter() → [yaml, body]
→ preProcessWikilinks(body) → replaces [[target]] with Unicode placeholder tokens
→ editor.tryParseMarkdownToBlocks() → BlockNote block tree
→ injectWikilinks(blocks) → walks tree, replaces placeholder text with wikilink inline content nodes
→ injectWikilinks(blocks) → walks tree, replaces placeholders with wikilink inline content nodes
→ editor.replaceBlocks()
```
Placeholder tokens use `\u2039` (single left-pointing angle quotation mark) and `\u203A` (single right-pointing) to avoid colliding with markdown syntax.
Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
### BlockNote-to-Markdown Pipeline (Save)
```
BlockNote blocks
→ editor.blocksToMarkdownLossy()
→ postProcessWikilinks() → restore [[target]] syntax from wikilink nodes
→ prepend frontmatter yaml
→ invoke('save_note_content', { path, content })
```
### Wikilink Navigation
Two navigation mechanisms:
1. **Click handler**: A DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements and calls `onNavigateWikilink(target)`.
1. **Click handler**: DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements `onNavigateWikilink(target)`.
2. **Suggestion menu**: Typing `[[` triggers `SuggestionMenuController` with filtered vault entries.
2. **Suggestion menu**: Typing `[[` triggers BlockNote's `SuggestionMenuController`, which shows a filtered list of all vault entries. Selecting one inserts a wikilink inline content node.
Wikilink resolution (`useNoteActions`) uses fuzzy matching: exact title → alias → path stem → filename stem → slug-to-words.
Wikilink resolution in `useNoteActions.handleNavigateWikilink()` uses fuzzy matching:
- Exact title match
- Alias match
- Path stem match (e.g., `person/matteo-cellini`)
- Filename stem match
- Slug-to-words match (e.g., `matteo-cellini``matteo cellini`)
### Raw Editor Mode
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
## Theme System
See [THEMING.md](./THEMING.md) for the full theme system documentation.
In brief: `src/theme.json` defines editor typography and styling as nested JSON. The `useEditorTheme` hook flattens it into CSS custom properties that are applied as inline styles on the BlockNote container.
### Overview
Two-layer theming:
1. **Global CSS variables** (`src/index.css`): App-wide colors via `:root`, bridged to Tailwind v4
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
### Vault-Based Themes
Themes are markdown notes in `theme/` with `Is A: Theme` frontmatter. Each property becomes a CSS variable with `--` prefix.
```yaml
---
Is A: Theme
Description: Light theme with warm, paper-like tones
background: "#FFFFFF"
foreground: "#37352F"
accent-blue: "#155DFF"
editor-font-size: 16
editor-line-height: 1.5
---
```
### ThemeManager
`useThemeManager` hook manages the theme lifecycle:
```typescript
interface ThemeManager {
themes: ThemeFile[]
activeThemeId: string | null
activeTheme: ThemeFile | null
isDark: boolean
switchTheme(themeId: string): Promise<void>
createTheme(name?: string): Promise<string>
reloadThemes(): Promise<void>
updateThemeProperty(key: string, value: string): Promise<void>
}
```
- Detects dark backgrounds via luminance calculation → sets `color-scheme` and `data-theme-mode`
- Live preview: re-applies when active theme note is saved
- Three built-in themes: Default (light), Dark (deep navy), Minimal (high contrast)
- Legacy JSON themes (`_themes/*.json`) supported for backward compatibility
### Theme Property Editor
`ThemePropertyEditor` component provides an interactive UI for editing theme properties. Uses `themeSchema.ts` to determine input types (color picker, number slider, text field) based on property names and values.
## Inspector Abstraction
The Inspector panel (`src/components/Inspector.tsx`) is composed of four sub-panels:
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs with two distinct sections:
- **Editable properties** (top): frontmatter fields the user can modify — shown with interactive hover styling (`hover:bg-muted`), cursor pointer, and click-to-edit. Includes Type badge, Status pill, boolean toggles, array tag pills, and text fields.
- **Info section** (bottom, separated by border): read-only derived metadata — Modified, Created, Words, File Size. Uses muted text color (`--text-muted`) with no hover states or click interaction. These fields are computed from file metadata and content, not from frontmatter.
- Keys in `SKIP_KEYS` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section since they are either internal or already displayed elsewhere (e.g., `is_a` is shown via the TypeRow badge).
2. **Relationships**: Shows `belongs_to` and `related_to` wikilinks as clickable chips.
3. **Backlinks**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
4. **Git History**: Shows the last few commits from `gitHistory` state.
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
- **Editable properties** (top): Type badge, Status pill with dropdown, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
- Keys in `SKIP_KEYS` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
Frontmatter parsing on the TypeScript side is handled by `src/utils/frontmatter.ts:parseFrontmatter()`, a lightweight YAML parser that handles strings, booleans, inline arrays, and multi-line lists.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, and all custom relationship fields as clickable wikilink chips.
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
4. **GitHistoryPanel**: Shows recent commits from file history with relative timestamps.
## Search & Indexing
### Search Modes
```typescript
type SearchMode = 'keyword' | 'semantic' | 'hybrid'
interface SearchResult {
title: string
path: string
snippet: string
score: number
}
interface SearchResponse {
results: SearchResult[]
elapsedMs: number
}
```
### Search Integration
`SearchPanel` component provides the search UI:
- Mode selector (keyword/semantic/hybrid)
- Real-time results as user types
- Click result to open note in editor
- Shows relevance score and snippet
### Indexing
Managed by `useIndexing` hook:
- Checks index status on vault load
- Two-phase indexing: scanning (parse files) → embedding (generate vectors)
- Progress streamed via Tauri events
- Incremental updates after git sync
- Metadata persisted in `.laputa-index.json`
## Vault Management
### Vault Switching
`useVaultSwitcher` hook manages multiple vaults:
- Persists vault list to `~/.config/com.laputa.app/vaults.json`
- Switching closes all tabs and resets sidebar
- Supports adding, removing, hiding/restoring vaults
- Default vault: Getting Started demo vault
### Vault Config
Per-vault settings stored in `config/ui.config.md`:
- Editable as a normal note (YAML frontmatter)
- Managed by `useVaultConfig` hook and `vaultConfigStore`
- Settings: zoom, view mode, tag colors, status colors, property display modes
- One-time migration from localStorage (`configMigration.ts`)
### Getting Started / Onboarding
`useOnboarding` hook detects first launch:
- If vault path doesn't exist → show `WelcomeScreen`
- User can create Getting Started vault or open existing folder
- Welcome state tracked in localStorage (`laputa_welcome_dismissed`)
### GitHub Integration
Device Authorization Flow for GitHub-backed vaults:
- `GitHubDeviceFlow` component handles OAuth
- `GitHubVaultModal` for cloning existing repos or creating new ones
- Token persisted in app settings for future git operations
- `SettingsPanel` shows connection status with disconnect option
## Settings
App-level settings persisted at `~/.config/com.laputa.app/settings.json`:
```typescript
interface Settings {
anthropic_key: string | null
openai_key: string | null
google_key: string | null
github_token: string | null
github_username: string | null
auto_pull_interval_minutes: number | null
}
```
Managed by `useSettings` hook and `SettingsPanel` component.

View File

@@ -9,46 +9,58 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
| Desktop shell | Tauri v2 | 2.10.0 |
| Frontend | React + TypeScript | React 19, TS 5.9 |
| Editor | BlockNote | 0.46.2 |
| Raw editor | CodeMirror 6 | - |
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
| UI primitives | Radix UI + shadcn/ui | - |
| Icons | Phosphor Icons + Lucide | - |
| Build | Vite | 7.3.1 |
| Backend language | Rust (edition 2021) | 1.77.2 |
| Frontmatter parsing | gray_matter | 0.2 |
| AI | Anthropic Claude API (Haiku 3.5 default) | - |
| AI (in-app chat) | Anthropic Claude API (Haiku 3.5 default) | - |
| AI (agent panel) | Claude CLI subprocess (streaming NDJSON) | - |
| Search | qmd (keyword + semantic + hybrid) | - |
| MCP | @modelcontextprotocol/sdk | 1.0 |
| Tests | Vitest (unit), Playwright (E2E), cargo test (Rust) | - |
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
| Package manager | pnpm | - |
## System Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Tauri v2 Window │
│ │
│ ┌─────────────────── React Frontend ───────────────────┐
│ │ │
│ │ App.tsx (orchestrator) │
│ │ ├── Sidebar (navigation + filters)
│ │ ├── NoteList (filtered note list)
│ │ ├── Editor (BlockNote + tabs + diff)
│ │ │ ├── Inspector (metadata + relationships) │
│ │ │ ── AIChatPanel (AI assistant + context)
│ │ ├── StatusBar (footer info) │
│ │ └── Modals (QuickOpen, CreateNote, CommitDialog) │
│ │
└──────────────┬──────────┬──────────────────────────┘
│ │
Tauri IPC│ Vite Proxy / WS
┌──────────────▼────┐ ┌──▼───────────────────────────┐
│ │ Rust Backend │ │ External Services
│ lib.rs → 10 cmds │ │ Anthropic API (Claude) │
│ vault/ MCP Server (ws://9710) │
frontmatter.rs │ │
│ git.rs │ └──────────────────────────────
│ │ ai_chat.rs │
└───────────────────┘
└─────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────
Tauri v2 Window
│ ┌─────────────────── React Frontend ────────────────────────┐ │
│ │ │ │
│ │ App.tsx (orchestrator) │ │
│ │ ├── WelcomeScreen (onboarding / vault-missing) │ │
│ │ ├── Sidebar (navigation + filters + types) │ │
│ │ ├── NoteList / PulseView (filtered list / activity) │ │
│ │ ├── Editor (BlockNote + tabs + diff + raw) │
│ │ │ ── Inspector (metadata + relationships) │ │
│ │ │ ├── AIChatPanel (API-based chat) │ │
│ │ │ └── AiPanel (Claude CLI agent + tools) │
│ │ ├── SearchPanel (keyword/semantic/hybrid search) │ │
│ ├── SettingsPanel (API keys, GitHub, zoom, theme) │
├── StatusBar (vault picker + sync + version) │
├── CommandPalette (Cmd+K fuzzy command launcher) │
│ └── Modals (CreateNote, CreateType, Commit, GitHub) │
│ │ │ │
└──────────────┬──────────┬──────────────────────────────────┘
Tauri IPC│ Vite Proxy / WS
┌──────────────▼────┐ ┌──▼────────────────────────────────
│ │ Rust Backend │ │ External Services
│ lib.rs → 61 cmds │ │ Anthropic API (Claude chat)
│ │ vault/ │ │ Claude CLI (agent subprocess) │ │
│ │ frontmatter/ │ │ MCP Server (ws://9710, 9711) │ │
│ │ git/ │ │ qmd (search/indexing engine) │ │
│ │ github/ │ │ GitHub API (OAuth, repos, clone) │ │
│ │ theme/ │ │ │ │
│ │ search.rs │ └───────────────────────────────────┘ │
│ │ indexing.rs │ │
│ │ claude_cli.rs │ │
│ └───────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
## Four-Panel Layout
@@ -57,71 +69,90 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
┌────────┬─────────────┬─────────────────────────┬────────────┐
│Sidebar │ Note List │ Editor │ Inspector │
│(250px) │ (300px) │ (flex-1) │ (280px) │
│ │ │ │ OR │
│ All │ [Search] │ [Tab Bar] │ AI Chat │
│ Favs │ [Type Pill] │ [Breadcrumb Bar] │
│ │ OR │ │ OR │
│ All │ Pulse View │ [Tab Bar] │ AI Chat │
│ Favs │ │ [Breadcrumb Bar] │ OR
│ Changes│ [Search] │ │ AI Agent │
│ Pulse │ [Sort/Filt] │ # My Note │ │
│ │ │ │ Context │
│Projects│ Note 1 │ # My Note │ Messages │
│Experim.│ Note 2 │ │ Actions │
│Respons.│ Note 3 │ Content here... │ Input │
│Procedu.│ ... │ │ │
│People │ │ │ │
│Projects│ Note 1 │ Content here... │ Messages │
│Experim.│ Note 2 │ (BlockNote or Raw) │ Actions │
│Respons.│ Note 3 │ │ Input │
│People │ ... │ │ │
│Events │ │ │ │
│Topics │ │ │ │
├────────┴─────────────┴─────────────────────────┴────────────┤
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ 3 pending notes
└─────────────────────────────────────────────────────────────┘
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ Vault: ~/Laputa
└─────────────────────────────────────────────────────────────
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites) and collapsible section groups (Projects, Experiments, Responsibilities, etc.)
- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, relationship groups, and orange dot indicators for uncommitted modified notes.
- **Editor** (flex, fills remaining space): Tab bar (with orange modified dots on dirty tabs), breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files. Decomposed into focused subcomponents: `Editor` (orchestrator), `EditorContent` (breadcrumb + editor/diff views), `EditorRightPanel` (inspector/AI toggle), `SingleEditorView` (BlockNote + suggestions), with hooks `useDiffMode` and `useEditorFocus`.
- **Inspector / AI Chat** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history) and AI Chat panel. The Sparkle icon in the breadcrumb bar toggles between them.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Tab bar with modified dots, breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`.
- **Inspector / AI Chat / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history), AI Chat panel (API-based), and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them.
Panels are separated by `ResizeHandle` components that support drag-to-resize.
## AI Chat System
## AI System
### Architecture
Laputa has two AI interfaces with distinct architectures:
The AI chat feature has three layers:
### AI Chat (AIChatPanel)
Simple chat mode — no tool execution, streaming text responses.
1. **Frontend** (`AIChatPanel` + `useAIChat` hook) — UI and state management
2. **API Proxy** (Vite middleware in dev, Rust `ai_chat` command in Tauri) — routes to Anthropic
3. **MCP Server** (`mcp-server/`) — vault operation tools for AI assistants
3. **Context picker** — selected notes sent as system context with token estimation
### Data Flow
### AI Agent (AiPanel)
Full agent mode — spawns Claude CLI as a subprocess with tool access and MCP vault integration.
1. **Frontend** (`AiPanel` + `useAiAgent` hook) — streaming UI with reasoning blocks, tool action cards, and response display
2. **Backend** (`claude_cli.rs`) — spawns `claude` binary with `--output-format stream-json`, parses NDJSON events
3. **MCP Integration** — passes vault MCP config via `--mcp-config` flag so the agent can search, read, and modify vault notes
#### Agent Event Flow
```
User types message in AIChatPanel
→ useAIChat.sendMessage(text)
→ buildSystemPrompt(contextNotes, allContent, model)
→ Assembles selected notes as system context
Estimates tokens, truncates if needed
→ streamChat(messages, systemPrompt, model, callbacks)
→ POST /api/ai/chat (Vite proxy → Anthropic API)
SSE stream parsed, chunks dispatched to onChunk callback
→ UI updates in real-time as tokens arrive
→ On completion: message added to conversation history
User sends message in AiPanel
→ useAiAgent.sendMessage(text, references)
→ buildContextSnapshot(activeNote, linkedNotes, openTabs)
→ invoke('stream_claude_agent', { message, systemPrompt, vaultPath })
Rust spawns: claude -p <msg> --output-format stream-json --mcp-config <json>
→ NDJSON lines parsed into ClaudeStreamEvent variants:
Init, TextDelta, ThinkingDelta, ToolStart, ToolDone, Result, Error, Done
Events emitted via Tauri: app_handle.emit("claude-agent-stream", &event)
→ Frontend listener routes events:
onText → accumulate response (revealed on Done)
onThinking → show reasoning block (collapsed on first text)
onToolStart → add AiActionCard with spinner
onToolDone → update card with output
onDone → reveal full response, detect file operations
```
### Context Picker
#### File Operation Detection
The context picker controls which notes are sent to the AI as context:
When the agent writes or edits vault files, `useAiAgent` detects this from tool inputs (Write/Edit tool JSON) and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
- **Current note** is auto-added when the panel opens
- **Add button** opens a search dropdown to select additional notes
- **Token estimation** shows approximate context size (~4 chars/token)
- **Truncation** kicks in when context exceeds 60% of model limit (108k tokens)
- Context pills show selected notes with remove buttons
### Context Building
### API Key Management
Both AI modes use context from the active note and linked entries. The agent panel (`ai-context.ts`) builds a structured JSON snapshot:
- Stored in `localStorage` under key `laputa:anthropic-api-key`
- Configurable via the key icon in the AI Chat header
- When no key is set, falls back to mock responses for testing
```json
{
"activeNote": { "path", "title", "type", "frontmatter", "content" },
"linkedNotes": [{ "path", "title", "content" }],
"openTabs": [{ "title", "snippet" }],
"vaultMetadata": { "noteTypes", "stats", "filter" },
"references": [{ "title", "path", "type" }]
}
```
### Models
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
### Models (Chat mode)
| Model | ID | Use case |
|-------|----|----------|
@@ -129,11 +160,17 @@ The context picker controls which notes are sent to the AI as context:
| Sonnet 4 | `claude-sonnet-4-20250514` | Balanced |
| Opus 4 | `claude-opus-4-20250514` | Most capable |
### MCP Server
### API Key Management
- Stored in app settings (`~/.config/com.laputa.app/settings.json`) under `anthropic_key`
- Configurable via Settings panel (also supports `openai_key`, `google_key`)
- Claude CLI (agent mode) uses its own authentication — no API key needed
## MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
#### Tool Surface (14 tools)
### Tool Surface (14 tools)
| Tool | Params | Description |
|------|--------|-------------|
@@ -146,30 +183,28 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
| `delete_note` | `path` | Delete a note file from the vault |
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
| `vault_context` | — | Get vault summary: entity types + 20 recent notes |
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
| `ui_open_tab` | `path` | Open a note in a new UI tab |
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
#### Transports
### Transports
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
- **WebSocket** — live bridge for Laputa app integration:
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
#### Auto-Registration
### Auto-Registration
On app startup, Laputa automatically registers itself as an MCP server in:
- `~/.claude/mcp.json` (Claude Code)
- `~/.cursor/mcp.json` (Cursor)
The registration is non-destructive (additive preserves other MCP servers) and uses `upsert` semantics. The entry points to `mcp-server/index.js` with the active vault path as `VAULT_PATH` env var.
Registration is non-destructive (additive, preserves other servers) and uses `upsert` semantics. The `useMcpStatus` hook tracks registration state (`checking | installed | not_installed | no_claude_cli`).
Registration also runs from the frontend via the `useMcpRegistration` hook and `register_mcp_tools` Tauri command, ensuring the config stays up-to-date when the vault path changes.
#### Architecture
### Architecture
```
┌─────────────────────────────────────────────────────┐
@@ -200,16 +235,16 @@ The WebSocket bridge enables real-time vault operations from both the frontend a
```
Frontend (useMcpBridge) ←→ ws://localhost:9710 ←→ ws-bridge.js ←→ vault.js
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions (useAiActivity)
```
**Tool bridge protocol** (port 9710):
- Request: `{ "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }`
- Response: `{ "id": "req-1", "result": { ... } }`
- Error: `{ "id": "req-1", "error": "message" }`
**UI bridge protocol** (port 9711):
- Broadcast: `{ "type": "ui_action", "action": "open_note", "path": "..." }`
- `useAiActivity` hook receives these and applies them (highlight with 800ms feedback, open note, set filter, etc.)
### Rust MCP Module
@@ -223,29 +258,132 @@ MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
### Rust Backend (Tauri)
## Search & Indexing
The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streaming alternative:
- Uses `reqwest` to call the Anthropic Messages API directly
- API key from `ANTHROPIC_API_KEY` environment variable
- Returns full response (not streamed)
- Used in production Tauri builds where Vite proxy is unavailable
### Search Engine
### Files
Search uses the external `qmd` binary (semantic search engine) with three modes:
| File | Purpose |
|------|---------|
| `src/components/AIChatPanel.tsx` | Main UI: context bar, messages, input, quick actions |
| `src/hooks/useAIChat.ts` | Chat state: messages, streaming, send/retry/clear |
| `src/hooks/useMcpBridge.ts` | WebSocket client for MCP vault tool calls |
| `src/hooks/useMcpRegistration.ts` | Auto-registers Laputa MCP on vault load |
| `src/utils/ai-chat.ts` | API client, token estimation, context builder |
| `src-tauri/src/ai_chat.rs` | Rust Anthropic API client (non-streaming) |
| `src-tauri/src/mcp.rs` | MCP server spawning + config registration |
| `mcp-server/index.js` | MCP server entry (stdio transport, 14 tools) |
| `mcp-server/vault.js` | Vault file operations (9 functions) |
| `mcp-server/ws-bridge.js` | WebSocket bridge server (tool + UI bridges) |
| `mcp-server/test.js` | 26 unit tests for all vault.js functions |
| Mode | Command | Description |
|------|---------|-------------|
| `keyword` | `qmd search` | Term matching (default) |
| `semantic` | `qmd vsearch` | Vector similarity search |
| `hybrid` | `qmd query` | Combined keyword + semantic |
### Indexing Flow
```
Vault opened
→ check_index_status() → parse qmd status output
→ if stale or missing:
→ start_indexing() (two phases):
Phase 1 (Scanning): qmd update — scan all .md files
Phase 2 (Embedding): qmd embed — generate vector embeddings
→ Progress streamed via Tauri "indexing-progress" event
→ Metadata saved to .laputa-index.json (last_indexed_commit, timestamp)
→ run_incremental_update() for subsequent changes
```
Embedding failure is non-fatal — keyword search still works.
### qmd Binary Resolution
1. Bundled macOS app resource: `<app>/Contents/Resources/qmd/qmd`
2. Dev mode: `CARGO_MANIFEST_DIR/resources/qmd/qmd`
3. System locations: `~/.bun/bin/qmd`, `/usr/local/bin/qmd`, `/opt/homebrew/bin/qmd`
4. PATH lookup via `which qmd`
5. Auto-install via `bun install -g qmd` if missing
## Vault Cache System
The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning using git-based incremental updates.
### Cache File
`.laputa-cache.json` at vault root. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan).
### Three Cache Strategies
1. **Same Commit (Cache Hit)**: Git HEAD matches cached hash → only re-parse uncommitted changed files via `git status --porcelain`
2. **Different Commit (Incremental Update)**: Uses `git diff <old>..<new> --name-only` to find changed files + uncommitted changes → selective re-parse
3. **No Cache / Corrupt Cache (Full Scan)**: Recursive `walkdir` of all `.md` files → full parse
Cache auto-excludes itself from git via `.git/info/exclude`.
## Theme System
See [THEMING.md](./THEMING.md) for the full theme system documentation.
### Two-Layer Architecture
1. **Global CSS variables** (`src/index.css`): App-wide colors, borders, backgrounds. Bridged to Tailwind v4 via `@theme inline`.
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`.
### Vault-Based Themes
Themes are markdown notes in the `theme/` folder with `Is A: Theme` frontmatter. Each frontmatter property becomes a CSS variable. Managed by `useThemeManager` hook and the `src-tauri/src/theme/` Rust module (create, seed, defaults).
- **Vault settings**: `.laputa/settings.json` stores the active theme reference
- **Legacy support**: `_themes/*.json` files still supported for backward compatibility
- **Built-in themes**: Default (light), Dark, Minimal — auto-seeded on vault open
- **Live preview**: Re-applies when the active theme note is saved
## Vault Management
### Vault List
Persisted at `~/.config/com.laputa.app/vaults.json`:
```json
{
"vaults": [{ "label": "My Vault", "path": "/path/to/vault" }],
"active_vault": "/path/to/vault",
"hidden_defaults": []
}
```
Managed by `useVaultSwitcher` hook. Switching vaults closes all tabs and resets sidebar.
### Vault Config
Per-vault UI settings stored in `config/ui.config.md` (YAML frontmatter in a markdown note):
- `zoom`: Float zoom level (0.81.5)
- `view_mode`: "all" | "editor-list" | "editor-only"
- `tag_colors`, `status_colors`: Custom color overrides
- `property_display_modes`: Property display preferences
### Getting Started Vault
On first launch, `useOnboarding` checks if the default vault exists. If not, shows `WelcomeScreen` with two options:
- **Create Getting Started vault** → calls `create_getting_started_vault()` Tauri command
- **Open an existing folder** → system file picker
### GitHub OAuth Integration
Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed vaults.
**Flow:**
1. User clicks "Login with GitHub" in Settings panel
2. `github_device_flow_start()` returns a user code + verification URL
3. User authorizes at `github.com/login/device`
4. App polls `github_device_flow_poll()` until authorized
5. Token stored in `~/.config/com.laputa.app/settings.json`
**Vault operations:**
- `GitHubVaultModal`: Clone existing repo or create new private/public repo
- `clone_repo()`: Clones with token-injected HTTPS URL
- Token persists for future git push/pull operations
## Pulse View
`PulseView` is a git activity feed that replaces the NoteList when the Pulse filter is selected.
- Groups commits by day ("Today", "Yesterday", or full date)
- Shows commit message, short hash, timestamp, and changed files
- Files have status icons (added/modified/deleted) and are clickable to open in editor
- Links to GitHub commits when `githubUrl` is available
- Infinite scroll pagination (20 commits per page) via Intersection Observer
Backend: `get_vault_pulse` Tauri command parses `git log` with `--name-status`.
## Data Flow
@@ -253,93 +391,201 @@ The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streamin
```
1. Tauri setup:
a. run_startup_tasks() → purge trash, migrate frontmatter, register MCP config
a. run_startup_tasks() → purge trash, migrate frontmatter, seed themes, migrate AGENTS.md, seed config files, register MCP
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
2. App mounts
3. useVaultLoader fires:
a. isTauri() ? invoke('list_vault') : mockInvoke('list_vault')
→ VaultEntry[] stored in state
b. Load all content (mock mode) or on-demand (Tauri mode)
c. invoke('get_modified_files') → ModifiedFile[] stored in state
d. useMcpRegistration → invoke('register_mcp_tools') → ensures MCP config current
4. User clicks note in NoteList
4. useNoteActions.handleSelectNote:
a. invoke('get_note_content') → raw markdown string
3. useOnboarding checks vault exists → WelcomeScreen if not
4. useVaultLoader fires:
a. invoke('list_vault', { path }) → scan_vault_cached() → VaultEntry[]
b. Load modified files via invoke('get_modified_files')
c. useMcpStatus → register MCP if needed
d. useThemeManager → load and apply active theme
e. useIndexing → check index status, trigger incremental update if needed
5. User clicks note in NoteList
6. useNoteActions.handleSelectNote:
a. invoke('get_note_content') → raw markdown
b. Add tab { entry, content } to tabs state
c. Set activeTabPath
5. Editor renders BlockNoteTab:
7. Editor renders BlockNoteTab:
a. splitFrontmatter(content) → [yaml, body]
b. preProcessWikilinks(body) → replaces [[target]] with tokens
c. editor.tryParseMarkdownToBlocks(preprocessed)
d. injectWikilinks(blocks) → replaces tokens with wikilink nodes
e. editor.replaceBlocks()
6. Inspector renders frontmatter parsed from content
8. Inspector renders frontmatter parsed from content
```
### Frontmatter Edit Flow
### Auto-Save Flow
```
User edits property in Inspector
handleUpdateFrontmatter(path, key, value)
Tauri: invoke('update_frontmatter') → Rust reads file, modifies YAML, writes back
Mock: updateMockFrontmatter() → client-side YAML manipulation
Update tab content in state
→ Update allContent for backlink recalculation
→ Toast: "Property updated"
Editor content changes
useEditorSave detects change (debounced)
serialize BlockNote blocks → markdown
postProcessWikilinks → restore [[target]] syntax
invoke('save_note_content', { path, content })
→ Update tab status indicator
```
### Git Flow
### Git Sync Flow
```
User clicks Commit button → CommitDialog opens
handleCommitPush(message)
→ invoke('git_commit') → git add -A && git commit -m "..."
→ invoke('git_push') → git push
useAutoSync (configurable interval, default from settings):
invoke('git_pull') → GitPullResult
→ if conflicts → ConflictResolverModal
→ if fast-forward → reload vault
→ invoke('git_push') → GitPushResult
Manual commit:
→ CommitDialog → invoke('git_commit', { message })
→ invoke('git_push')
→ Reload modified files
→ Toast: "Committed and pushed"
```
## Vault Module Structure
The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| File | Purpose | CodeScene Health |
|------|---------|-----------------|
| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship extraction | 10.0 |
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` | 9.68 |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers | 9.68 |
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days | 9.38 |
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault | 9.68 |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames | 10.0 |
| File | Purpose |
|------|---------|
| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship/link extraction |
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days |
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | Frontmatter migration utilities |
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
| `getting_started.rs` | Creates the Getting Started demo vault |
Public API (re-exported from `mod.rs`): `scan_vault_cached`, `save_image`, `rename_note`, `RenameResult`, `purge_trash`, `get_note_content`, `parse_md_file`, `VaultEntry`.
## Rust Backend Modules
## Tauri IPC Commands
| Module | Purpose |
|--------|---------|
| `vault/` | Vault scanning, caching, parsing, trash, rename, image, migration |
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
| `theme/` | Theme management (`mod.rs`, `create.rs`, `defaults.rs`, `seed.rs`) |
| `search.rs` | qmd search integration (keyword/semantic/hybrid) |
| `indexing.rs` | qmd indexing with progress streaming |
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
| `ai_chat.rs` | Direct Anthropic API client (non-streaming, for Tauri builds) |
| `mcp.rs` | MCP server spawning + config registration |
| `commands.rs` | All 61 Tauri command handlers |
| `settings.rs` | App settings persistence |
| `vault_config.rs` | Per-vault UI config |
| `vault_list.rs` | Vault list persistence |
| `menu.rs` | Native macOS menu bar |
All commands are defined in `src-tauri/src/lib.rs` and registered via `tauri::generate_handler![]`.
## Tauri IPC Commands (61 total)
| Command | Params | Returns | Backend function |
|---------|--------|---------|-----------------|
| `list_vault` | `path` | `Vec<VaultEntry>` | `vault::scan_vault()` |
| `get_note_content` | `path` | `String` | `vault::get_note_content()` |
| `update_frontmatter` | `path, key, value` | `String` (updated content) | `frontmatter::with_frontmatter()` |
| `delete_frontmatter_property` | `path, key` | `String` (updated content) | `frontmatter::with_frontmatter()` |
| `get_file_history` | `vault_path, path` | `Vec<GitCommit>` | `git::get_file_history()` |
| `get_modified_files` | `vault_path` | `Vec<ModifiedFile>` | `git::get_modified_files()` |
| `get_file_diff` | `vault_path, path` | `String` (unified diff) | `git::get_file_diff()` |
| `git_commit` | `vault_path, message` | `String` | `git::git_commit()` |
| `git_push` | `vault_path` | `String` | `git::git_push()` |
| `ai_chat` | `request: AiChatRequest` | `AiChatResponse` | `ai_chat::send_chat()` |
| `register_mcp_tools` | `vault_path` | `String` ("registered" or "updated") | `mcp::register_mcp()` |
### Vault Operations
All commands return `Result<T, String>`. Errors are serialized as JSON error objects to the frontend.
| Command | Description |
|---------|-------------|
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
| `get_note_content` | Read note file content |
| `save_note_content` | Write note content to disk |
| `delete_note` | Move note to trash |
| `rename_note` | Rename note + update cross-vault wikilinks |
| `batch_archive_notes` | Archive multiple notes |
| `batch_trash_notes` | Trash multiple notes |
| `purge_trash` | Delete notes trashed >30 days ago |
| `check_vault_exists` | Check if vault path exists |
| `create_getting_started_vault` | Bootstrap demo vault |
### Frontmatter
| Command | Description |
|---------|-------------|
| `update_frontmatter` | Update a frontmatter property |
| `delete_frontmatter_property` | Remove a frontmatter property |
### Git
| Command | Description |
|---------|-------------|
| `git_commit` | Stage all + commit |
| `git_pull` | Pull from remote |
| `git_push` | Push to remote |
| `git_resolve_conflict` | Resolve a merge conflict |
| `git_commit_conflict_resolution` | Commit conflict resolution |
| `get_file_history` | Last N commits for a file |
| `get_modified_files` | `git status` filtered to .md |
| `get_file_diff` | Unified diff for a file |
| `get_file_diff_at_commit` | Diff at a specific commit |
| `get_conflict_files` | List conflicted files |
| `get_conflict_mode` | Get conflict resolution mode |
| `get_vault_pulse` | Git activity feed (paginated) |
| `get_last_commit_info` | Latest commit metadata |
### GitHub
| Command | Description |
|---------|-------------|
| `github_device_flow_start` | Begin OAuth device flow |
| `github_device_flow_poll` | Poll for authorization |
| `github_get_user` | Get authenticated user info |
| `github_list_repos` | List user's repos |
| `github_create_repo` | Create new repo |
| `clone_repo` | Clone repo with token auth |
### Search & Indexing
| Command | Description |
|---------|-------------|
| `search_vault` | Search via qmd (keyword/semantic/hybrid) |
| `get_index_status` | Check qmd index state |
| `start_indexing` | Full index with progress streaming |
| `trigger_incremental_index` | Incremental index update |
### Theme
| Command | Description |
|---------|-------------|
| `list_themes` | List all themes (legacy JSON) |
| `get_theme` | Read a theme file |
| `get_vault_settings` | Read `.laputa/settings.json` |
| `save_vault_settings` | Write vault settings |
| `set_active_theme` | Set active theme ID |
| `create_theme` | Create JSON theme from template |
| `create_vault_theme` | Create markdown theme note |
| `ensure_vault_themes` | Seed default themes if missing |
| `restore_default_themes` | Restore all default themes |
| `repair_vault` | Restore default themes + missing config files |
### AI & MCP
| Command | Description |
|---------|-------------|
| `ai_chat` | Direct Anthropic API call (non-streaming) |
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
| `check_claude_cli` | Check if Claude CLI is available |
| `register_mcp_tools` | Register MCP in Claude/Cursor config |
| `check_mcp_status` | Check MCP registration state |
### Settings & Config
| Command | Description |
|---------|-------------|
| `get_settings` | Load app settings |
| `save_settings` | Save app settings |
| `load_vault_list` | Load vault list |
| `save_vault_list` | Save vault list |
| `get_vault_config` | Load per-vault UI config |
| `save_vault_config` | Save per-vault UI config |
| `get_default_vault_path` | Get default vault path |
| `get_build_number` | Get app build number |
| `save_image` | Save base64 image to vault |
| `copy_image_to_vault` | Copy image file to vault |
| `update_menu_state` | Update native menu checkmarks |
## Mock Layer
When running outside Tauri (browser at `localhost:5201`), `src/mock-tauri.ts` provides a transparent mock layer:
When running outside Tauri (browser at `localhost:5173`), `src/mock-tauri.ts` provides a transparent mock layer:
```typescript
// In hooks, the pattern is always:
if (isTauri()) {
result = await invoke<T>('command_name', { args })
} else {
@@ -347,14 +593,7 @@ if (isTauri()) {
}
```
The mock layer includes:
- **15 sample entries** across all entity types (Project, Responsibility, Procedure, Experiment, Note, Person, Event, Topic, Essay)
- **Full markdown content** with realistic frontmatter for each entry
- **Mock git history, modified files, and diff output**
- **Mock AI chat responses** with context-aware answers (summarize, expand, grammar)
- `addMockEntry()` and `updateMockContent()` for runtime updates
This means the entire UI can be developed and tested in Chrome without the Rust backend.
The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits.
## State Management
@@ -362,11 +601,18 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| State owner | State | Purpose |
|-------------|-------|---------|
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, `showAIChat` | UI state |
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
| `useNoteActions` | `tabs`, `activeTabPath` | Open tabs and note operations |
| `useAIChat` | `messages`, `isStreaming`, `streamingContent` | AI conversation state |
| `useMcpBridge` | `connected`, tool methods | MCP WebSocket connection |
| `useTabManagement` | Tab ordering, pinning, swapping | Tab lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
| `useThemeManager` | `themes`, `activeThemeId`, `isDark` | Theme state |
| `useAIChat` | `messages`, `isStreaming` | AI chat conversation |
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useIndexing` | Index status, progress | Search indexing |
| `useSettings` | App settings (API keys, GitHub token) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
@@ -374,10 +620,14 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Shortcut | Action |
|----------|--------|
| Cmd+P | Open Quick Open palette |
| Cmd+N | Open Create Note dialog |
| Cmd+S | Show "Saved" toast |
| Cmd+K | Open command palette |
| Cmd+P | Open quick open palette |
| Cmd+N | Create new note |
| Cmd+S | Save current note |
| Cmd+W | Close active tab |
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
| Cmd+19 | Switch to tab N |
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
## Auto-Release & In-App Updates
@@ -399,47 +649,22 @@ push to main
→ generate latest.json (per-arch + universal platform entries)
→ publish GitHub Release with all assets + auto-generated notes
→ pages job:
→ fetch all releases via gh api
→ build static HTML release history page
→ deploy to gh-pages via peaceiris/actions-gh-pages
→ deploy to gh-pages
```
### Versioning
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). The `0.` prefix keeps it SemVer-compatible while making it clear these are date-based auto-releases. The version is stamped into both `tauri.conf.json` and `Cargo.toml` dynamically in the workflow.
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). Stamped into `tauri.conf.json` and `Cargo.toml` dynamically.
### Universal Binary
macOS builds produce both `aarch64-apple-darwin` and `x86_64-apple-darwin` in parallel. The release job merges them with `lipo` — copying the arm64 `.app` as the base and replacing only the main executable with a universal fat binary. The per-arch updater tarballs are also uploaded so the Tauri updater downloads only the relevant architecture (smaller download).
### Updater Endpoint
The Tauri updater plugin is configured to fetch:
```
https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json
```
This JSON manifest contains `version`, `pub_date`, `notes`, and per-platform entries (`darwin-aarch64`, `darwin-x86_64`) with `url` and `signature` fields. The updater compares the manifest version against the running app version, downloads the matching platform artifact, verifies the signature, and installs it.
### In-App Update UI
### In-App Updates
```
App startup (3s delay)
→ useUpdater.check()
→ idle (no update) → no UI shown
→ available → UpdateBanner: "Laputa X.Y.Z is available" + Release Notes + Update Now + X
user clicks Update Now → downloading → progress bar
download complete → ready → "Restart to apply" + Restart Now button
→ user clicks Restart → relaunch()
→ network error / 404 → fail silently, no UI
→ idle (no update) → no UI
→ available → UpdateBanner with release notes + "Update Now"
→ downloading → progress bar
→ ready → "Restart to apply" + Restart Now
→ network error → fail silently
```
| Component | File | Purpose |
|-----------|------|---------|
| `useUpdater` | `src/hooks/useUpdater.ts` | State machine: idle → available → downloading → ready → error |
| `UpdateBanner` | `src/components/UpdateBanner.tsx` | Top-of-app notification bar |
| `restartApp` | `src/hooks/useUpdater.ts` | Calls `@tauri-apps/plugin-process` relaunch |
### GitHub Pages
Release history site at `https://refactoringhq.github.io/laputa-app/`. Auto-updated by the workflow after each release. The page loads `releases.json` (deployed alongside) and renders each release with date, notes, and `.dmg` download links. Linked from the in-app "Release Notes" button.

View File

@@ -7,6 +7,7 @@ How to navigate the codebase, run the app, and find what you need.
- **Node.js** 18+ and **pnpm**
- **Rust** 1.77.2+ (for the Tauri backend)
- **git** CLI (required by the git integration features)
- **qmd** (optional — for search indexing; auto-installed if missing)
## Quick Start
@@ -24,7 +25,7 @@ pnpm tauri dev
# Run tests
pnpm test # Vitest unit tests
cargo test # Rust tests (from src-tauri/)
pnpm test:e2e # Playwright E2E tests
pnpm playwright:smoke # Playwright smoke tests
```
## Directory Structure
@@ -33,39 +34,94 @@ pnpm test:e2e # Playwright E2E tests
laputa-app/
├── src/ # React frontend
│ ├── main.tsx # Entry point (renders <App />)
│ ├── App.tsx # Root component — orchestrates 4-panel layout
│ ├── App.tsx # Root component — orchestrates layout + state
│ ├── App.css # App shell layout styles
│ ├── types.ts # Shared TS types (VaultEntry, GitCommit, etc.)
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
│ ├── theme.json # Editor theme configuration
│ ├── index.css # Global CSS variables + Tailwind setup
│ │
│ ├── components/ # UI components
│ │ ├── Sidebar.tsx # Left panel: filters + section groups
│ ├── components/ # UI components (~98 files)
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
│ │ ├── SidebarParts.tsx # Sidebar subcomponents
│ │ ├── NoteList.tsx # Second panel: filtered note list
│ │ ├── Editor.tsx # Third panel: tabs + BlockNote + diff
│ │ ├── NoteItem.tsx # Individual note item
│ │ ├── PulseView.tsx # Git activity feed (replaces NoteList)
│ │ ├── Editor.tsx # Third panel: tabs + editor orchestration
│ │ ├── EditorContent.tsx # Editor content area
│ │ ├── EditorRightPanel.tsx # Right panel toggle
│ │ ├── editorSchema.tsx # BlockNote schema + wikilink type
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
│ │ ├── EditableValue.tsx # Inline value editor component
│ │ ├── DiffView.tsx # Git diff viewer
│ │ ├── ResizeHandle.tsx # Draggable panel divider
│ │ ├── StatusBar.tsx # Bottom status bar
│ │ ├── QuickOpenPalette.tsx # Cmd+P command palette
│ │ ├── CreateNoteDialog.tsx # New note modal
│ │ ├── AIChatPanel.tsx # AI chat (API-based)
│ │ ├── AiPanel.tsx # AI agent (Claude CLI subprocess)
│ │ ├── AiMessage.tsx # Agent message display
│ │ ├── AiActionCard.tsx # Agent tool action cards
│ │ ├── SearchPanel.tsx # Search interface
│ │ ├── SettingsPanel.tsx # App settings
│ │ ├── StatusBar.tsx # Bottom bar: vault picker + sync
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
│ │ ├── TabBar.tsx # Tab management
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
│ │ ├── WelcomeScreen.tsx # Onboarding screen
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
│ │ ├── ThemePropertyEditor.tsx # Interactive theme editor
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
│ │ ├── CommitDialog.tsx # Git commit modal
│ │ ├── Toast.tsx # Toast notifications
│ │ ├── Editor.css # Editor layout styles
│ │ ├── EditorTheme.css # BlockNote theme overrides
│ │ ── ui/ # shadcn/ui primitives (button, dialog, etc.)
│ │ ├── CreateNoteDialog.tsx # New note modal
│ │ ├── CreateTypeDialog.tsx # New type modal
│ │ ├── UpdateBanner.tsx # In-app update notification
│ │ ── inspector/ # Inspector sub-panels
│ │ │ ├── BacklinksPanel.tsx
│ │ │ ├── RelationshipsPanel.tsx
│ │ │ ├── GitHistoryPanel.tsx
│ │ │ └── ...
│ │ └── ui/ # shadcn/ui primitives
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
│ │
│ ├── hooks/ # Custom React hooks
│ │ ├── useVaultLoader.ts # Loads vault entries, git status, content
│ │ ├── useNoteActions.ts # Tab management, frontmatter CRUD, navigation
│ │ ── useTheme.ts # Flattens theme.json into CSS variables
│ ├── hooks/ # Custom React hooks (~87 files)
│ │ ├── useVaultLoader.ts # Loads vault entries + content
│ │ ├── useVaultSwitcher.ts # Multi-vault management
│ │ ── useVaultConfig.ts # Per-vault UI settings
│ │ ├── useNoteActions.ts # Tab management, navigation, CRUD
│ │ ├── useTabManagement.ts # Tab ordering + lifecycle
│ │ ├── useAIChat.ts # AI chat state
│ │ ├── useAiAgent.ts # AI agent state + tool tracking
│ │ ├── useAiActivity.ts # MCP UI bridge listener
│ │ ├── useAutoSync.ts # Auto git pull/push
│ │ ├── useConflictResolver.ts # Git conflict handling
│ │ ├── useEditorSave.ts # Auto-save with debounce
│ │ ├── useTheme.ts # Flatten theme.json → CSS vars
│ │ ├── useThemeManager.ts # Vault theme lifecycle
│ │ ├── useIndexing.ts # Search indexing management
│ │ ├── useNoteSearch.ts # Note search
│ │ ├── useCommandRegistry.ts # Command palette registry
│ │ ├── useAppCommands.ts # App-level commands
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
│ │ ├── useSettings.ts # App settings
│ │ ├── useOnboarding.ts # First-launch flow
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
│ │ ├── useMcpBridge.ts # MCP WebSocket client
│ │ ├── useMcpStatus.ts # MCP registration status
│ │ ├── useUpdater.ts # In-app updates
│ │ └── ...
│ │
│ ├── utils/ # Pure utility functions
│ │ ├── frontmatter.ts # TypeScript YAML frontmatter parser
│ │ ── wikilinks.ts # Wikilink preprocessing + word count
│ ├── utils/ # Pure utility functions (~48 files)
│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline
│ │ ── frontmatter.ts # TypeScript YAML parser
│ │ ├── ai-agent.ts # Agent stream utilities
│ │ ├── ai-chat.ts # Chat API client + token estimation
│ │ ├── ai-context.ts # Context snapshot builder
│ │ ├── noteListHelpers.ts # Sorting, filtering, date formatting
│ │ ├── themeSchema.ts # Theme editor schema builder
│ │ ├── configMigration.ts # localStorage → vault config migration
│ │ ├── iconRegistry.ts # Phosphor icon registry
│ │ ├── propertyTypes.ts # Property type definitions
│ │ ├── vaultListStore.ts # Vault list persistence
│ │ ├── vaultConfigStore.ts # Vault config store
│ │ └── ...
│ │
│ ├── lib/
│ │ └── utils.ts # Tailwind merge + cn() helper
@@ -78,28 +134,58 @@ laputa-app/
│ ├── build.rs # Tauri build script
│ ├── tauri.conf.json # Tauri app configuration
│ ├── capabilities/ # Tauri v2 security capabilities
│ │ └── default.json
│ ├── src/
│ │ ├── main.rs # Entry point (calls lib::run())
│ │ ├── lib.rs # Tauri command registration (9 commands)
│ │ ├── vault.rs # Vault scanning + markdown parsing
│ │ ├── frontmatter.rs # YAML frontmatter manipulation
│ │ └── git.rs # Git CLI operations
│ │ ├── lib.rs # Tauri setup + command registration (61 commands)
│ │ ├── commands.rs # All Tauri command handlers
│ │ ├── vault/ # Vault module
│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault
│ │ │ ├── cache.rs # Git-based incremental caching
│ │ │ ├── parsing.rs # Text processing + title extraction
│ │ │ ├── trash.rs # Trash auto-purge
│ │ │ ├── rename.rs # Rename + cross-vault wikilink update
│ │ │ ├── image.rs # Image attachment saving
│ │ │ ├── migration.rs # Frontmatter migration
│ │ │ └── getting_started.rs # Getting Started vault creation
│ │ ├── frontmatter/ # Frontmatter module
│ │ │ ├── mod.rs, yaml.rs, ops.rs
│ │ ├── git/ # Git module
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
│ │ ├── github/ # GitHub module
│ │ │ ├── mod.rs, auth.rs, api.rs, clone.rs
│ │ ├── theme/ # Theme module
│ │ │ ├── mod.rs, create.rs, defaults.rs, seed.rs
│ │ ├── search.rs # qmd search integration
│ │ ├── indexing.rs # qmd indexing + progress streaming
│ │ ├── claude_cli.rs # Claude CLI subprocess management
│ │ ├── ai_chat.rs # Direct Anthropic API client
│ │ ├── mcp.rs # MCP server lifecycle + registration
│ │ ├── settings.rs # App settings persistence
│ │ ├── vault_config.rs # Per-vault UI config
│ │ ├── vault_list.rs # Vault list persistence
│ │ └── menu.rs # Native macOS menu bar
│ └── icons/ # App icons
├── e2e/ # Playwright E2E tests
│ ├── app.spec.ts # App loading tests
│ ├── core-flows.spec.ts # Main user workflows
│ ├── keyboard-shortcuts.spec.ts
│ ├── quick-open.spec.ts
── screenshot.spec.ts # Visual regression screenshots
└── ...
├── mcp-server/ # MCP bridge (Node.js)
│ ├── index.js # MCP server entry (stdio, 14 tools)
│ ├── vault.js # Vault file operations
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
│ ├── test.js # MCP server tests
── package.json
├── e2e/ # Playwright E2E tests (~26 specs)
├── tests/smoke/ # Smoke tests (~10 specs)
├── design/ # Per-task design files
├── demo-vault-v2/ # Getting Started demo vault
├── scripts/ # Build/utility scripts
├── package.json # Frontend dependencies + scripts
├── vite.config.ts # Vite bundler config
├── tsconfig.json # TypeScript config
├── playwright.config.ts # E2E test config
├── CLAUDE.md # Project instructions for Claude
├── ui-design.pen # Master design file
├── CLAUDE.md # Project instructions
└── docs/ # This documentation
```
@@ -109,9 +195,10 @@ laputa-app/
| File | Why it matters |
|------|---------------|
| `src/App.tsx` | The root component. Shows how the 4-panel layout is assembled and how state flows between components. |
| `src/App.tsx` | Root component. Shows the 4-panel layout, state flow, and how all features connect. |
| `src/types.ts` | All shared TypeScript types. Read this first to understand the data model. |
| `src-tauri/src/lib.rs` | All 9 Tauri commands in one place. This is the frontend-backend API surface. |
| `src-tauri/src/commands.rs` | All 61 Tauri command handlers. This is the frontend-backend API surface. |
| `src-tauri/src/lib.rs` | Tauri setup, command registration, startup tasks, WebSocket bridge lifecycle. |
### Data layer
@@ -119,31 +206,55 @@ laputa-app/
|------|---------------|
| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. |
| `src/hooks/useNoteActions.ts` | Tab management, wikilink navigation, frontmatter CRUD. The biggest hook. |
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, Getting Started vault. |
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
### Backend
| File | Why it matters |
|------|---------------|
| `src-tauri/src/vault.rs` | Vault scanning, frontmatter parsing, entity type inference. The core backend logic. |
| `src-tauri/src/frontmatter.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git.rs` | All git operations. Shells out to git CLI. |
| `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. |
| `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. |
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). |
| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. |
| `src-tauri/src/search.rs` | qmd search integration (keyword/semantic/hybrid). |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
### Editor
| File | Why it matters |
|------|---------------|
| `src/components/Editor.tsx` | BlockNote setup, custom wikilink schema, tab bar, breadcrumb bar, diff toggle. |
| `src/utils/wikilinks.ts` | The wikilink preprocessing pipeline (markdown → BlockNote blocks with wikilinks). |
| `src/components/EditorTheme.css` | BlockNote CSS overrides for typography and styling. |
| `src/components/Editor.tsx` | BlockNote setup, tab bar, breadcrumb bar, diff/raw toggle. |
| `src/components/editorSchema.tsx` | Custom wikilink inline content type definition. |
| `src/utils/wikilinks.ts` | Wikilink preprocessing pipeline (markdown ↔ BlockNote). |
| `src/components/RawEditorView.tsx` | CodeMirror 6 raw markdown editor. |
### Styling
### AI
| File | Why it matters |
|------|---------------|
| `src/index.css` | All CSS custom properties (colors, spacing). The design token source of truth. |
| `src/components/AiPanel.tsx` | AI agent panel — Claude CLI with tool execution, reasoning, actions. |
| `src/components/AIChatPanel.tsx` | AI chat panel — API-based chat without tools. |
| `src/hooks/useAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
### Styling & Themes
| File | Why it matters |
|------|---------------|
| `src/index.css` | All CSS custom properties. The design token source of truth. |
| `src/theme.json` | Editor-specific theme (fonts, headings, lists, code blocks). |
| `src/hooks/useTheme.ts` | Converts theme.json into CSS variables for the editor. |
| `src/hooks/useThemeManager.ts` | Vault theme lifecycle (switch, create, apply, live preview). |
| `docs/THEMING.md` | Full theme system documentation. |
### Settings & Config
| File | Why it matters |
|------|---------------|
| `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). |
| `src/hooks/useVaultConfig.ts` | Per-vault UI preferences (zoom, view mode, colors). |
| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection. |
## Architecture Patterns
@@ -169,13 +280,15 @@ No global state management (no Redux, no Context). `App.tsx` owns the state and
```typescript
type SidebarSelection =
| { kind: 'filter'; filter: 'all' | 'favorites' }
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string }
| { kind: 'entity'; entry: VaultEntry }
| { kind: 'topic'; entry: VaultEntry }
```
This pattern makes it easy to handle all selection states exhaustively.
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. The native macOS menu bar also triggers commands via `useMenuEvents`.
## Running Tests
@@ -183,26 +296,28 @@ This pattern makes it easy to handle all selection states exhaustively.
# Unit tests (fast, no browser)
pnpm test
# Rust tests
cd src-tauri && cargo test
# Unit tests with coverage (must pass ≥70%)
pnpm test:coverage
# E2E tests (requires dev server)
pnpm test:e2e
# Rust tests
cargo test
# Rust coverage (must pass ≥85% line coverage)
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
# Playwright smoke tests (requires dev server)
BASE_URL="http://localhost:5173" pnpm playwright:smoke
# Single Playwright test
npx playwright test e2e/screenshot.spec.ts
# Visual verification screenshots
npx playwright test e2e/screenshot.spec.ts
# Screenshots saved to test-results/
BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
```
## Common Tasks
### Add a new Tauri command
1. Write the Rust function in `vault.rs`, `git.rs`, or a new module
2. Add `#[tauri::command]` wrapper in `lib.rs`
1. Write the Rust function in the appropriate module (`vault/`, `git/`, etc.)
2. Add a command handler in `commands.rs`
3. Register it in the `generate_handler![]` macro in `lib.rs`
4. Call it from the frontend via `invoke()` in the appropriate hook
5. Add a mock handler in `mock-tauri.ts`
@@ -217,6 +332,25 @@ npx playwright test e2e/screenshot.spec.ts
### Add a new entity type
1. Create the folder in the vault (e.g., `~/Laputa/mytype/`)
2. Add the folder → type mapping in `vault.rs:parse_md_file()` (the `match` on folder names)
3. The sidebar section groups are defined as `SECTION_GROUPS` in `Sidebar.tsx` — add it there
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it
2. Create a type document: `type/mytype.md` with `Is A: Type` frontmatter (icon, color, order, etc.)
3. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true`
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog
### Add a command palette entry
1. Register the command in `useAppCommands.ts` via the command registry
2. Add a corresponding menu bar item in `menu.rs` for discoverability
3. If it has a keyboard shortcut, register it in `useAppKeyboard.ts`
### Add or modify a theme
1. **Vault-based** (preferred): Create/edit a markdown note in `theme/` with `Is A: Theme` frontmatter
2. **Programmatic**: Edit defaults in `src-tauri/src/theme/defaults.rs`
3. See `docs/THEMING.md` for the full property reference
### Work with the AI agent
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)

View File

@@ -10,6 +10,8 @@
* - 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
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
* - refresh_vault: trigger vault rescan so new/modified files appear
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
@@ -94,6 +96,28 @@ const TOOLS = [
required: ['path'],
},
},
{
name: 'highlight_editor',
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
inputSchema: {
type: 'object',
properties: {
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' },
path: { type: 'string', description: 'Optional note path to associate with the highlight' },
},
required: ['element'],
},
},
{
name: 'refresh_vault',
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Optional specific note path that changed' },
},
},
},
]
const TOOL_HANDLERS = {
@@ -101,6 +125,8 @@ const TOOL_HANDLERS = {
get_vault_context: handleVaultContext,
get_note: handleGetNote,
open_note: handleOpenNote,
highlight_editor: handleHighlightEditor,
refresh_vault: handleRefreshVault,
}
async function handleSearchNotes(args) {
@@ -122,14 +148,27 @@ async function handleGetNote(args) {
}
function handleOpenNote(args) {
// Refresh vault first so the new/modified note appears in the note list,
// then signal the UI to open it in a tab.
broadcastUiAction('vault_changed', { path: args.path })
broadcastUiAction('open_tab', { path: args.path })
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
}
function handleHighlightEditor(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
}
function handleRefreshVault(args) {
broadcastUiAction('vault_changed', { path: args?.path })
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
}
// --- Server setup ---
const server = new Server(
{ name: 'laputa-mcp-server', version: '0.2.0' },
{ name: 'laputa-mcp-server', version: '0.3.0' },
{ capabilities: { tools: {} } },
)

View File

@@ -4,8 +4,7 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import {
readNote, createNote, searchNotes, appendToNote, findMarkdownFiles,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
let tmpDir
@@ -65,39 +64,23 @@ describe('findMarkdownFiles', () => {
})
})
describe('readNote', () => {
it('should read a note by relative path', async () => {
const content = await readNote(tmpDir, 'project/test-project.md')
assert.ok(content.includes('Test Project'))
assert.ok(content.includes('is_a: Project'))
describe('getNote', () => {
it('should read a note with parsed frontmatter', async () => {
const note = await getNote(tmpDir, 'project/test-project.md')
assert.equal(note.path, 'project/test-project.md')
assert.equal(note.frontmatter.title, 'Test Project')
assert.equal(note.frontmatter.is_a, 'Project')
assert.ok(note.content.includes('test project for the MCP server'))
})
it('should throw for missing notes', async () => {
await assert.rejects(
() => readNote(tmpDir, 'nonexistent.md'),
() => getNote(tmpDir, 'nonexistent.md'),
{ code: 'ENOENT' }
)
})
})
describe('createNote', () => {
it('should create a note with frontmatter', async () => {
const absPath = await createNote(tmpDir, 'note/new-note.md', 'My New Note', { is_a: 'Note' })
assert.ok(absPath.endsWith('new-note.md'))
const content = await fs.readFile(absPath, 'utf-8')
assert.ok(content.includes('title: My New Note'))
assert.ok(content.includes('is_a: Note'))
assert.ok(content.includes('# My New Note'))
})
it('should create parent directories', async () => {
const absPath = await createNote(tmpDir, 'deep/nested/dir/note.md', 'Deep Note')
const content = await fs.readFile(absPath, 'utf-8')
assert.ok(content.includes('# Deep Note'))
})
})
describe('searchNotes', () => {
it('should find notes matching title', async () => {
const results = await searchNotes(tmpDir, 'Test Project')
@@ -121,123 +104,6 @@ describe('searchNotes', () => {
})
})
describe('appendToNote', () => {
it('should append text to a note', async () => {
await appendToNote(tmpDir, 'note/daily-log.md', '## Evening Update\nFinished testing.')
const content = await readNote(tmpDir, 'note/daily-log.md')
assert.ok(content.includes('## Evening Update'))
assert.ok(content.includes('Finished testing.'))
})
})
describe('editNoteFrontmatter', () => {
it('should merge a patch into frontmatter', async () => {
const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' })
assert.equal(updated.status, 'Completed')
assert.equal(updated.priority, 'High')
assert.equal(updated.title, 'Test Project')
})
it('should preserve existing frontmatter fields', async () => {
const content = await readNote(tmpDir, 'project/test-project.md')
assert.ok(content.includes('is_a: Project'))
assert.ok(content.includes('status: Completed'))
})
it('should throw for missing file', async () => {
await assert.rejects(
() => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }),
{ code: 'ENOENT' }
)
})
})
describe('deleteNote', () => {
it('should delete an existing note', async () => {
const delPath = 'note/to-delete.md'
await createNote(tmpDir, delPath, 'To Delete')
const absPath = path.join(tmpDir, delPath)
// Verify it exists
await fs.access(absPath)
await deleteNote(tmpDir, delPath)
await assert.rejects(
() => fs.access(absPath),
{ code: 'ENOENT' }
)
})
it('should throw for missing file', async () => {
await assert.rejects(
() => deleteNote(tmpDir, 'nonexistent.md'),
{ code: 'ENOENT' }
)
})
})
describe('linkNotes', () => {
it('should add a target to an array property', async () => {
const linkPath = 'project/link-test.md'
await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' })
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
assert.deepEqual(result, ['[[note/daily-log]]'])
})
it('should not duplicate existing links', async () => {
const linkPath = 'project/link-test.md'
await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
assert.equal(result.length, 1)
})
it('should add multiple distinct links', async () => {
const linkPath = 'project/link-test.md'
await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
// Should have daily-log and test-project
assert.ok(result.includes('[[note/daily-log]]'))
assert.ok(result.includes('[[project/test-project]]'))
assert.equal(result.length, 2)
})
})
describe('listNotes', () => {
it('should list all notes sorted by title', async () => {
const notes = await listNotes(tmpDir)
assert.ok(notes.length >= 3)
// Verify sorted by title
for (let i = 1; i < notes.length; i++) {
assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0)
}
})
it('should filter by type', async () => {
const projects = await listNotes(tmpDir, 'Project')
assert.ok(projects.length >= 1)
for (const n of projects) {
assert.equal(n.type, 'Project')
}
})
it('should return empty for unknown type', async () => {
const notes = await listNotes(tmpDir, 'UnknownType12345')
assert.equal(notes.length, 0)
})
it('should support mtime sorting', async () => {
const notes = await listNotes(tmpDir, undefined, 'mtime')
assert.ok(notes.length >= 1)
// Just verify it returns results without crashing
assert.ok(notes[0].path)
assert.ok(notes[0].title)
})
})
describe('vaultContext', () => {
it('should return types, recent notes, and vault path', async () => {
const ctx = await vaultContext(tmpDir)
@@ -264,4 +130,15 @@ describe('vaultContext', () => {
assert.ok(note.title)
}
})
it('should include folders', async () => {
const ctx = await vaultContext(tmpDir)
assert.ok(ctx.folders.includes('project/'))
assert.ok(ctx.folders.includes('note/'))
})
it('should report correct note count', async () => {
const ctx = await vaultContext(tmpDir)
assert.equal(ctx.noteCount, 3)
})
})

View File

@@ -107,11 +107,22 @@ export async function vaultContext(vaultPath) {
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
// Read config files for AI agent context
const configFiles = {}
try {
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
const agentsContent = await fs.readFile(agentsPath, 'utf-8')
configFiles.agents = agentsContent
} catch {
// config/agents.md may not exist yet
}
return {
types: [...typesSet].sort(),
noteCount: files.length,
folders: [...foldersSet].sort(),
recentNotes,
configFiles,
vaultPath,
}
}

View File

@@ -46,10 +46,12 @@ const TOOL_HANDLERS = {
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),
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_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); 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', { filterType: args.type }); return { ok: true } },
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
}
async function handleMessage(data) {

View File

@@ -73,6 +73,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"esbuild": "^0.27.3",
@@ -86,6 +87,7 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
"vitest": "^4.0.18",
"ws": "^8.19.0"
}
}

13
pnpm-lock.yaml generated
View File

@@ -168,6 +168,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@vitejs/plugin-react':
specifier: ^5.1.1
version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))
@@ -210,6 +213,9 @@ importers:
vitest:
specifier: ^4.0.18
version: 4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)
ws:
specifier: ^8.19.0
version: 8.19.0
mcp-server:
dependencies:
@@ -2043,6 +2049,9 @@ packages:
'@types/use-sync-external-store@1.5.0':
resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.55.0':
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -6011,6 +6020,10 @@ snapshots:
'@types/use-sync-external-store@1.5.0': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.10.13
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2

View File

@@ -5,7 +5,9 @@ use crate::claude_cli::{
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
};
use crate::frontmatter::FrontmatterValue;
use crate::git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
use crate::git::{
GitCommit, GitPullResult, GitPushResult, LastCommitInfo, ModifiedFile, PulseCommit,
};
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use crate::indexing::{IndexStatus, IndexingProgress};
use crate::search::SearchResponse;
@@ -221,10 +223,12 @@ pub fn get_file_diff_at_commit(
pub fn get_vault_pulse(
vault_path: String,
limit: Option<usize>,
skip: 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)
let limit = limit.unwrap_or(20);
let skip = skip.unwrap_or(0);
git::get_vault_pulse(&vault_path, limit, skip)
}
#[tauri::command]
@@ -274,7 +278,7 @@ pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, Stri
}
#[tauri::command]
pub fn git_push(vault_path: String) -> Result<String, String> {
pub fn git_push(vault_path: String) -> Result<GitPushResult, String> {
let vault_path = expand_tilde(&vault_path);
git::git_push(&vault_path)
}
@@ -507,6 +511,16 @@ pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
theme::restore_default_themes(&vault_path)
}
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
// Repair themes
theme::restore_default_themes(&vault_path)?;
// Repair config files (config/agents.md, type/config.md, AGENTS.md stub)
vault::repair_config_files(&vault_path)?;
Ok("Vault repaired".to_string())
}
// ── Settings & config commands ──────────────────────────────────────────────
#[tauri::command]

View File

@@ -193,8 +193,8 @@ mod tests {
#[test]
fn test_to_yaml_value_number_float() {
let v = FrontmatterValue::Number(3.14);
assert_eq!(v.to_yaml_value(), "3.14");
let v = FrontmatterValue::Number(3.125);
assert_eq!(v.to_yaml_value(), "3.125");
}
#[test]

View File

@@ -15,7 +15,7 @@ pub use conflict::{
};
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
pub use remote::{git_pull, git_push, has_remote, GitPullResult};
pub use remote::{git_pull, git_push, has_remote, GitPullResult, GitPushResult};
pub use status::{get_modified_files, ModifiedFile};
use serde::Serialize;
@@ -36,6 +36,34 @@ pub fn init_repo(path: &str) -> Result<(), String> {
run_git(dir, &["init"])?;
ensure_author_config(dir)?;
// Write .gitignore before the first commit so machine-specific and
// macOS metadata files are never tracked and don't cause conflicts.
let gitignore_path = dir.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(
&gitignore_path,
"# Laputa app files (machine-specific, never commit)\n\
.laputa-cache.json\n\
.laputa/settings.json\n\
\n\
# macOS\n\
.DS_Store\n\
.AppleDouble\n\
.LSOverride\n\
\n\
# Thumbnails\n\
._*\n\
\n\
# Editors\n\
.vscode/\n\
.idea/\n\
*.swp\n\
*.swo\n",
)
.map_err(|e| format!("Failed to write .gitignore: {}", e))?;
}
run_git(dir, &["add", "."])?;
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
@@ -236,6 +264,52 @@ mod tests {
);
}
#[test]
fn test_init_repo_creates_gitignore_with_ds_store() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let gitignore = vault.join(".gitignore");
assert!(
gitignore.exists(),
".gitignore should be created by init_repo"
);
let content = fs::read_to_string(&gitignore).unwrap();
assert!(
content.contains(".DS_Store"),
".gitignore should exclude .DS_Store"
);
assert!(
content.contains(".laputa-cache.json"),
".gitignore should exclude .laputa-cache.json"
);
assert!(
content.contains(".laputa/settings.json"),
".gitignore should exclude settings.json"
);
}
#[test]
fn test_init_repo_does_not_overwrite_existing_gitignore() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
fs::write(vault.join(".gitignore"), "custom-rule\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let content = fs::read_to_string(vault.join(".gitignore")).unwrap();
assert_eq!(
content, "custom-rule\n",
"existing .gitignore should not be overwritten"
);
}
#[test]
fn test_parse_github_repo_path_https() {
assert_eq!(

View File

@@ -53,7 +53,12 @@ fn parse_file_status(code: &str) -> &str {
}
/// 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> {
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
pub fn get_vault_pulse(
vault_path: &str,
limit: usize,
skip: usize,
) -> Result<Vec<PulseCommit>, String> {
let vault = Path::new(vault_path);
if !vault.join(".git").exists() {
@@ -61,6 +66,7 @@ pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result<Vec<PulseCommit
}
let limit_str = limit.to_string();
let skip_str = skip.to_string();
let output = Command::new("git")
.args([
"log",
@@ -69,6 +75,8 @@ pub fn get_vault_pulse(vault_path: &str, limit: usize) -> Result<Vec<PulseCommit
"--diff-filter=ADM",
"-n",
&limit_str,
"--skip",
&skip_str,
"--",
"*.md",
])
@@ -255,7 +263,7 @@ mod tests {
fs::write(vault.join("project.md"), "# Project\n").unwrap();
git_commit(vp, "Add project").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert_eq!(pulse.len(), 2);
assert_eq!(pulse[0].message, "Add project");
@@ -273,7 +281,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let vp = dir.path().to_str().unwrap();
let result = get_vault_pulse(vp, 30);
let result = get_vault_pulse(vp, 30, 0);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Not a git repository"));
}
@@ -283,7 +291,7 @@ mod tests {
let dir = setup_git_repo();
let vp = dir.path().to_str().unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert!(pulse.is_empty());
}
@@ -297,7 +305,7 @@ mod tests {
fs::write(vault.join("config.json"), "{}").unwrap();
git_commit(vp, "Add files").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert_eq!(pulse.len(), 1);
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "note.md");
@@ -318,7 +326,7 @@ mod tests {
git_commit(vp, &format!("Add note {}", i)).unwrap();
}
let pulse = get_vault_pulse(vp, 3).unwrap();
let pulse = get_vault_pulse(vp, 3, 0).unwrap();
assert_eq!(pulse.len(), 3);
}
@@ -334,7 +342,7 @@ mod tests {
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
git_commit(vp, "Update note").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert_eq!(pulse[0].message, "Update note");
assert_eq!(pulse[0].files[0].status, "modified");
assert_eq!(pulse[0].modified, 1);
@@ -360,7 +368,7 @@ mod tests {
.output()
.unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).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/"));
@@ -375,7 +383,7 @@ mod tests {
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
let pulse = get_vault_pulse(vp, 30).unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert!(pulse[0].github_url.is_none());
}

View File

@@ -110,8 +110,74 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
.collect()
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitPushResult {
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
pub message: String,
}
/// Classify a git push stderr message into a user-friendly status and message.
pub fn classify_push_error(stderr: &str) -> GitPushResult {
let lower = stderr.to_lowercase();
if lower.contains("non-fast-forward")
|| lower.contains("[rejected]")
|| lower.contains("fetch first")
|| lower.contains("failed to push some refs")
&& (lower.contains("updates were rejected") || lower.contains("non-fast-forward"))
{
return GitPushResult {
status: "rejected".to_string(),
message: "Push rejected: remote has new commits. Pull first, then push.".to_string(),
};
}
if lower.contains("authentication failed")
|| lower.contains("could not read username")
|| lower.contains("permission denied")
|| lower.contains("403")
|| lower.contains("invalid credentials")
{
return GitPushResult {
status: "auth_error".to_string(),
message: "Push failed: authentication error. Check your credentials.".to_string(),
};
}
if lower.contains("could not resolve host")
|| lower.contains("unable to access")
|| lower.contains("connection refused")
|| lower.contains("network is unreachable")
|| lower.contains("timed out")
{
return GitPushResult {
status: "network_error".to_string(),
message: "Push failed: network error. Check your connection and try again.".to_string(),
};
}
// Fallback: extract the hint line if present, otherwise use the full stderr
let hint_line = stderr
.lines()
.find(|l| l.trim_start().starts_with("hint:"))
.map(|l| l.trim_start().strip_prefix("hint:").unwrap_or(l).trim())
.unwrap_or("")
.to_string();
let detail = if hint_line.is_empty() {
stderr.trim().to_string()
} else {
hint_line
};
GitPushResult {
status: "error".to_string(),
message: format!("Push failed: {detail}"),
}
}
/// Push to remote.
pub fn git_push(vault_path: &str) -> Result<String, String> {
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
@@ -122,13 +188,13 @@ pub fn git_push(vault_path: &str) -> Result<String, String> {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git push failed: {}", stderr));
return Ok(classify_push_error(&stderr));
}
// git push often writes to stderr even on success
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(format!("{}{}", stdout, stderr))
Ok(GitPushResult {
status: "ok".to_string(),
message: "Pushed to remote".to_string(),
})
}
#[cfg(test)]
@@ -227,6 +293,104 @@ mod tests {
assert!(files.is_empty());
}
#[test]
fn test_classify_push_error_non_fast_forward() {
let stderr = r#"To github.com:user/repo.git
! [rejected] main -> main (non-fast-forward)
error: failed to push some refs to 'github.com:user/repo.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally."#;
let result = classify_push_error(stderr);
assert_eq!(result.status, "rejected");
assert!(result.message.contains("Pull first"));
}
#[test]
fn test_classify_push_error_fetch_first() {
let stderr = "error: failed to push some refs\nhint: Updates were rejected because the tip of your current branch is behind\nhint: its remote counterpart. Integrate the remote changes (e.g.\nhint: 'git pull ...') before pushing again.\nhint: See the 'Note about fast-forwards' in 'git push --help' for details.\n ! [rejected] main -> main (fetch first)\n";
let result = classify_push_error(stderr);
assert_eq!(result.status, "rejected");
}
#[test]
fn test_classify_push_error_auth_failure() {
let stderr = "remote: Permission denied to user/repo.git\nfatal: unable to access 'https://github.com/user/repo.git/': The requested URL returned error: 403";
let result = classify_push_error(stderr);
assert_eq!(result.status, "auth_error");
assert!(result.message.contains("authentication"));
}
#[test]
fn test_classify_push_error_network() {
let stderr = "fatal: unable to access 'https://github.com/user/repo.git/': Could not resolve host: github.com";
let result = classify_push_error(stderr);
assert_eq!(result.status, "network_error");
assert!(result.message.contains("network"));
}
#[test]
fn test_classify_push_error_unknown() {
let stderr = "error: something unexpected happened\nhint: Try again later";
let result = classify_push_error(stderr);
assert_eq!(result.status, "error");
assert!(result.message.contains("Try again later"));
}
#[test]
fn test_classify_push_error_unknown_no_hint() {
let stderr = "error: something totally weird";
let result = classify_push_error(stderr);
assert_eq!(result.status, "error");
assert!(result.message.contains("something totally weird"));
}
#[test]
fn test_git_push_result_serialization() {
let result = GitPushResult {
status: "rejected".to_string(),
message: "Push rejected".to_string(),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"rejected\""));
let parsed: GitPushResult = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.status, "rejected");
}
#[test]
fn test_git_push_success_returns_ok() {
let (_bare, clone_a, _clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
git_commit(vp_a, "initial").unwrap();
let result = git_push(vp_a).unwrap();
assert_eq!(result.status, "ok");
}
#[test]
fn test_git_push_rejected_returns_rejected() {
let (_bare, clone_a, clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
let vp_b = clone_b.path().to_str().unwrap();
// Both clones commit and push — second push should be rejected
fs::write(clone_a.path().join("note.md"), "# A\n").unwrap();
git_commit(vp_a, "from A").unwrap();
git_push(vp_a).unwrap();
git_pull(vp_b).unwrap();
fs::write(clone_b.path().join("note.md"), "# B\n").unwrap();
git_commit(vp_b, "from B").unwrap();
git_push(vp_b).unwrap();
// Now A has a new commit but hasn't pulled B's changes
fs::write(clone_a.path().join("other.md"), "# Other\n").unwrap();
git_commit(vp_a, "from A again").unwrap();
let result = git_push(vp_a).unwrap();
assert_eq!(result.status, "rejected");
assert!(result.message.contains("Pull first"));
}
#[test]
fn test_git_pull_result_serialization() {
let result = GitPullResult {

View File

@@ -1,4 +1,4 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
@@ -223,10 +223,72 @@ pub struct IndexStatus {
pub indexed_count: usize,
pub embedded_count: usize,
pub pending_embed: usize,
pub last_indexed_commit: Option<String>,
pub last_indexed_at: Option<u64>,
}
// --- Index metadata persistence ---
#[derive(Debug, Serialize, Deserialize, Default)]
struct IndexMetadata {
#[serde(default)]
last_indexed_commit: Option<String>,
#[serde(default)]
last_indexed_at: Option<u64>,
}
fn index_metadata_path(vault_path: &str) -> PathBuf {
Path::new(vault_path).join(".laputa-index.json")
}
fn load_index_metadata(vault_path: &str) -> IndexMetadata {
let path = index_metadata_path(vault_path);
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_index_metadata(vault_path: &str, meta: &IndexMetadata) -> Result<(), String> {
let path = index_metadata_path(vault_path);
let json =
serde_json::to_string_pretty(meta).map_err(|e| format!("Failed to serialize: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Failed to write index metadata: {e}"))
}
/// Get the current HEAD commit hash for a vault.
fn get_head_commit(vault_path: &str) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(vault_path)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
/// Record the current HEAD as the last indexed commit.
fn stamp_index_commit(vault_path: &str) {
if let Some(commit) = get_head_commit(vault_path) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let meta = IndexMetadata {
last_indexed_commit: Some(commit),
last_indexed_at: Some(now),
};
let _ = save_index_metadata(vault_path, &meta);
}
}
/// Check whether the vault has a qmd index and its status.
pub fn check_index_status(vault_path: &str) -> IndexStatus {
let meta = load_index_metadata(vault_path);
let qmd = match find_qmd_binary() {
Some(b) => b,
None => {
@@ -237,6 +299,8 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
last_indexed_commit: meta.last_indexed_commit,
last_indexed_at: meta.last_indexed_at,
}
}
};
@@ -244,7 +308,7 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
let vault_name = vault_dir_name(vault_path);
let output = qmd.command().args(["status"]).output();
match output {
let mut status = match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
parse_status_for_vault(&stdout, &vault_name)
@@ -256,8 +320,14 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
last_indexed_commit: None,
last_indexed_at: None,
},
}
};
status.last_indexed_commit = meta.last_indexed_commit;
status.last_indexed_at = meta.last_indexed_at;
status
}
fn vault_dir_name(vault_path: &str) -> String {
@@ -323,6 +393,8 @@ fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus
indexed_count,
embedded_count,
pending_embed,
last_indexed_commit: None,
last_indexed_at: None,
}
}
@@ -392,7 +464,9 @@ where
ensure_collection(vault_path)?;
// Phase 1: update (scan files)
let vault_name = vault_dir_name(vault_path);
// Phase 1: update (scan files) — scoped to this vault's collection only
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: 0,
@@ -403,7 +477,7 @@ where
let update_output = qmd
.command()
.args(["update"])
.args(["update", &vault_name])
.output()
.map_err(|e| format!("qmd update failed: {e}"))?;
@@ -432,7 +506,7 @@ where
error: None,
});
// Phase 2: embed (generate vectors)
// Phase 2: embed (generate vectors) — scoped to this vault's collection only
on_progress(IndexingProgress {
phase: "embedding".to_string(),
current: 0,
@@ -443,7 +517,7 @@ where
let embed_output = qmd
.command()
.args(["embed"])
.args(["embed", "-c", &vault_name])
.output()
.map_err(|e| format!("qmd embed failed: {e}"))?;
@@ -451,6 +525,7 @@ where
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}");
stamp_index_commit(vault_path);
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
@@ -461,6 +536,8 @@ where
return Ok(());
}
stamp_index_commit(vault_path);
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
@@ -504,7 +581,7 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
let output = qmd
.command()
.args(["update"])
.args(["update", &vault_name])
.output()
.map_err(|e| format!("qmd incremental update failed: {e}"))?;
@@ -513,9 +590,23 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
return Err(format!("qmd update failed: {stderr}"));
}
stamp_index_commit(vault_path);
Ok(())
}
/// Check if HEAD has advanced past the last indexed commit.
#[cfg(test)]
fn needs_reindex_after_sync(vault_path: &str) -> bool {
let meta = load_index_metadata(vault_path);
let head = get_head_commit(vault_path);
match (meta.last_indexed_commit, head) {
(Some(last), Some(current)) => last != current,
(None, Some(_)) => true,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -698,4 +789,126 @@ Collections
// It verifies the function doesn't panic.
let _ = find_bun();
}
#[test]
fn index_metadata_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Default when no file exists
let meta = load_index_metadata(vault);
assert!(meta.last_indexed_commit.is_none());
assert!(meta.last_indexed_at.is_none());
// Write and read back
let meta = IndexMetadata {
last_indexed_commit: Some("abc123def456".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
let loaded = load_index_metadata(vault);
assert_eq!(loaded.last_indexed_commit.as_deref(), Some("abc123def456"));
assert_eq!(loaded.last_indexed_at, Some(1709000000));
}
#[test]
fn index_metadata_survives_malformed_json() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Write garbage
std::fs::write(dir.path().join(".laputa-index.json"), "not json").unwrap();
let meta = load_index_metadata(vault);
assert!(meta.last_indexed_commit.is_none());
}
#[test]
fn needs_reindex_after_sync_no_metadata() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Init a git repo so get_head_commit works
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
// No metadata → needs reindex
assert!(needs_reindex_after_sync(vault));
}
#[test]
fn needs_reindex_after_sync_same_commit() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
let head = get_head_commit(vault).unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some(head),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
assert!(!needs_reindex_after_sync(vault));
}
#[test]
fn needs_reindex_after_sync_different_commit() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some("old_commit_hash".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
assert!(needs_reindex_after_sync(vault));
}
#[test]
fn check_index_status_includes_metadata() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some("abc123".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
let status = check_index_status(vault);
assert_eq!(status.last_indexed_commit.as_deref(), Some("abc123"));
assert_eq!(status.last_indexed_at, Some(1709000000));
}
}

View File

@@ -44,11 +44,22 @@ fn run_startup_tasks() {
"Migrated is_a to type on startup",
vault::migrate_is_a_to_type(vp_str),
);
log_startup_result(
"Migrated hidden_sections to visible property",
vault_config::migrate_hidden_sections_to_visible(vp_str),
);
// Seed _themes/ with built-in JSON themes (legacy) if missing
theme::seed_default_themes(vp_str);
// Seed theme/ with built-in vault theme notes if missing
theme::seed_vault_themes(vp_str);
// Seed type/theme.md so the Theme type has an icon in the sidebar
let _ = theme::ensure_theme_type_definition(vp_str);
// Migrate root AGENTS.md → config/agents.md (one-time, idempotent)
vault::migrate_agents_md(vp_str);
// Seed config/ with default config files if missing
vault::seed_config_files(vp_str);
// Register Laputa MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
@@ -161,6 +172,7 @@ pub fn run() {
commands::create_vault_theme,
commands::ensure_vault_themes,
commands::restore_default_themes,
commands::repair_vault,
commands::get_vault_config,
commands::save_vault_config
])

View File

@@ -32,7 +32,6 @@ 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";
@@ -49,6 +48,8 @@ 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 VAULT_REINDEX: &str = "vault-reindex";
const VAULT_REPAIR: &str = "vault-repair";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
@@ -75,7 +76,6 @@ const CUSTOM_IDS: &[&str] = &[
VIEW_GO_BACK,
VIEW_GO_FORWARD,
GO_ALL_NOTES,
GO_FAVORITES,
GO_ARCHIVED,
GO_TRASH,
GO_CHANGES,
@@ -90,6 +90,8 @@ const CUSTOM_IDS: &[&str] = &[
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
VAULT_REPAIR,
];
/// IDs of menu items that should be disabled when no note tab is active.
@@ -249,9 +251,6 @@ 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)?;
@@ -268,7 +267,6 @@ fn build_go_menu(app: &App) -> MenuResult {
Ok(SubmenuBuilder::new(app, "Go")
.item(&all_notes)
.item(&favorites)
.item(&archived)
.item(&trash)
.item(&changes)
@@ -335,9 +333,15 @@ fn build_vault_menu(app: &App) -> MenuResult {
let view_changes = MenuItemBuilder::new("View Pending Changes")
.id(VAULT_VIEW_CHANGES)
.build(app)?;
let install_mcp = MenuItemBuilder::new("Install MCP Server")
let install_mcp = MenuItemBuilder::new("Restore MCP Server")
.id(VAULT_INSTALL_MCP)
.build(app)?;
let reindex = MenuItemBuilder::new("Reindex Vault")
.id(VAULT_REINDEX)
.build(app)?;
let repair = MenuItemBuilder::new("Repair Vault")
.id(VAULT_REPAIR)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Vault")
.item(&open_vault)
@@ -351,6 +355,8 @@ fn build_vault_menu(app: &App) -> MenuResult {
.item(&resolve_conflicts)
.item(&view_changes)
.separator()
.item(&reindex)
.item(&repair)
.item(&install_mcp)
.build()?)
}
@@ -454,7 +460,6 @@ mod tests {
VIEW_GO_BACK,
VIEW_GO_FORWARD,
GO_ALL_NOTES,
GO_FAVORITES,
GO_ARCHIVED,
GO_TRASH,
GO_CHANGES,
@@ -469,6 +474,7 @@ mod tests {
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");

View File

@@ -329,3 +329,15 @@ editor-max-width: 680\n\
# Minimal Theme\n\
\n\
High contrast, minimal chrome. Monospace typography throughout.\n";
/// Type definition for the Theme note type.
pub const THEME_TYPE_DEFINITION: &str = "---\n\
Is A: Type\n\
icon: palette\n\
color: purple\n\
order: 50\n\
---\n\
\n\
# Theme\n\
\n\
A visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n";

View File

@@ -10,7 +10,8 @@ use std::path::Path;
pub use create::{create_theme, create_vault_theme};
pub use defaults::*;
pub use seed::{
ensure_vault_themes, restore_default_themes, seed_default_themes, seed_vault_themes,
ensure_theme_type_definition, ensure_vault_themes, restore_default_themes, seed_default_themes,
seed_vault_themes,
};
/// A theme file parsed from _themes/*.json in the vault.

View File

@@ -104,9 +104,25 @@ pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
// Seed theme/ markdown notes (reuses ensure_vault_themes for consistency)
ensure_vault_themes(vault_path)?;
// Seed type/theme.md so the Theme type has an icon and label in the sidebar
ensure_theme_type_definition(vault_path)?;
Ok("Default themes restored".to_string())
}
/// Create `type/theme.md` if it doesn't exist (gives the Theme type a sidebar icon/color).
pub fn ensure_theme_type_definition(vault_path: &str) -> Result<(), String> {
let type_dir = Path::new(vault_path).join("type");
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
let path = type_dir.join("theme.md");
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&path, THEME_TYPE_DEFINITION)
.map_err(|e| format!("Failed to write type/theme.md: {e}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -244,6 +260,46 @@ mod tests {
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
assert!(vault.join("theme").join("minimal.md").exists());
assert!(
vault.join("type").join("theme.md").exists(),
"restore must create type/theme.md"
);
let type_content = fs::read_to_string(vault.join("type").join("theme.md")).unwrap();
assert!(type_content.contains("Is A: Type"));
assert!(type_content.contains("icon: palette"));
}
#[test]
fn test_ensure_theme_type_definition_creates_file() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
ensure_theme_type_definition(vp).unwrap();
let path = vault.join("type").join("theme.md");
assert!(path.exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Is A: Type"));
assert!(content.contains("icon: palette"));
}
#[test]
fn test_ensure_theme_type_definition_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let type_dir = vault.join("type");
fs::create_dir_all(&type_dir).unwrap();
let custom = "---\nIs A: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
fs::write(type_dir.join("theme.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
ensure_theme_type_definition(vp).unwrap();
let content = fs::read_to_string(type_dir.join("theme.md")).unwrap();
assert!(
content.contains("swatches"),
"existing content must be preserved"
);
}
#[test]

View File

@@ -7,7 +7,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
// --- Vault Cache ---
/// Bump this when VaultEntry fields change to force a full rescan.
const CACHE_VERSION: u32 = 4;
const CACHE_VERSION: u32 = 5;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {
@@ -79,15 +79,10 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
.map(|s| collect_md_paths_from_diff(&s))
.unwrap_or_default();
// Use ls-files for untracked files so that newly-seeded directories are picked up
// as individual files rather than as a single "?? dirname/" entry.
// Include uncommitted changes (modified, staged, and untracked files).
let uncommitted = git_uncommitted_files(vault);
// Also include modified-but-unstaged files via status --porcelain.
let modified = run_git(vault, &["status", "--porcelain"])
.map(|s| collect_md_paths_from_porcelain(&s))
.unwrap_or_default();
for path in uncommitted.into_iter().chain(modified) {
for path in uncommitted.into_iter() {
if !files.contains(&path) {
files.push(path);
}
@@ -97,9 +92,30 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
}
fn git_uncommitted_files(vault: &Path) -> Vec<String> {
run_git(vault, &["status", "--porcelain"])
// Modified/staged tracked files from git status --porcelain
let mut files: Vec<String> = run_git(vault, &["status", "--porcelain"])
.map(|s| collect_md_paths_from_porcelain(&s))
.unwrap_or_default()
.unwrap_or_default();
// Untracked files via ls-files (lists individual files, not just directories).
// git status --porcelain shows `?? dir/` for new directories, hiding individual
// files inside — ls-files resolves them so the cache picks up all new .md files.
let untracked = run_git(vault, &["ls-files", "--others", "--exclude-standard"])
.map(|s| {
s.lines()
.filter(|l| !l.is_empty() && l.ends_with(".md"))
.map(|l| l.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
for path in untracked {
if !files.contains(&path) {
files.push(path);
}
}
files
}
fn load_cache(vault: &Path) -> Option<VaultCache> {
@@ -140,21 +156,48 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
.collect()
}
/// Ensure `.laputa-cache.json` is excluded from git via `.git/info/exclude`.
/// This prevents the cache (which contains machine-specific absolute paths)
/// from being committed and causing stale-path bugs on cloned vaults.
/// Machine-local files that should never be git-tracked in any vault.
/// These are either caches with absolute paths or per-machine settings.
const UNTRACKED_FILES: &[&str] = &[".laputa-cache.json", ".laputa/settings.json"];
/// Ensure machine-local files are excluded from git via `.git/info/exclude`
/// and un-tracked if they were previously committed (git rm --cached).
/// Called on every cache write so existing vaults self-heal automatically.
fn ensure_cache_excluded(vault: &Path) {
let exclude_path = vault.join(".git/info/exclude");
let entry = ".laputa-cache.json";
if let Ok(content) = fs::read_to_string(&exclude_path) {
if content.lines().any(|line| line.trim() == entry) {
return;
}
let separator = if content.ends_with('\n') { "" } else { "\n" };
let _ = fs::write(&exclude_path, format!("{content}{separator}{entry}\n"));
} else if exclude_path.parent().map(|p| p.is_dir()).unwrap_or(false) {
let _ = fs::write(&exclude_path, format!("{entry}\n"));
let git_dir = vault.join(".git");
if !git_dir.is_dir() {
return;
}
let exclude_path = git_dir.join("info").join("exclude");
// 1. Add each entry to .git/info/exclude so git ignores it going forward.
let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
let mut to_add: Vec<&str> = UNTRACKED_FILES
.iter()
.filter(|e| !existing.lines().any(|line| line.trim() == **e))
.copied()
.collect();
if !to_add.is_empty() {
to_add.sort();
let separator = if existing.ends_with('\n') || existing.is_empty() {
""
} else {
"\n"
};
let additions = to_add.join("\n");
let _ = fs::write(&exclude_path, format!("{existing}{separator}{additions}\n"));
}
// 2. Un-track each file if git currently tracks it.
// `git rm --cached --quiet --ignore-unmatch` exits 0 even if the file isn't tracked.
// This fixes existing vaults where these files were committed before this guard.
let _ = std::process::Command::new("git")
.args(["rm", "--cached", "--quiet", "--ignore-unmatch", "--"])
.args(UNTRACKED_FILES)
.current_dir(vault)
.output();
}
/// Sort entries by modified_at descending and write the cache.
@@ -541,4 +584,125 @@ mod tests {
assert!(titles.contains(&"Existing"));
assert!(titles.contains(&"New Note"));
}
#[test]
fn test_update_same_commit_new_files_in_new_subdirectory() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "existing.md", "# Existing\n");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// Prime cache
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
// Create files in a new subdirectory (simulates restore_default_themes)
create_test_file(
vault,
"theme/default.md",
"---\nIs A: Theme\n---\n# Default Theme\n",
);
create_test_file(
vault,
"theme/dark.md",
"---\nIs A: Theme\n---\n# Dark Theme\n",
);
// Cache same commit — files in new subdirectory must appear
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(
entries2.len(),
3,
"must pick up files in new untracked subdirectory"
);
let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect();
assert!(titles.contains(&"Existing"));
assert!(titles.contains(&"Default Theme"));
assert!(titles.contains(&"Dark Theme"));
}
#[test]
fn test_update_same_commit_visible_removed_from_type_note() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
// Commit a type note with visible: false
create_test_file(
vault,
"type/topic.md",
"---\ntype: Type\nvisible: false\n---\n# Topic\n",
);
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// Prime the cache
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].visible,
Some(false),
"visible must be false initially"
);
// User removes visible field (uncommitted edit)
create_test_file(vault, "type/topic.md", "---\ntype: Type\n---\n# Topic\n");
// Reload — must reflect the removal (visible defaults to None)
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1);
assert_eq!(
entries2[0].visible, None,
"visible must be None after removing the field"
);
}
}

View File

@@ -0,0 +1,355 @@
use std::fs;
use std::path::Path;
use super::getting_started::AGENTS_MD;
/// Content for `type/config.md` — gives the Config type a sidebar icon and label.
const CONFIG_TYPE_DEFINITION: &str = "\
---
Is A: Type
icon: gear-six
color: gray
order: 90
sidebar label: Config
---
# Config
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
";
/// Minimal root `AGENTS.md` stub that redirects to `config/agents.md`.
const AGENTS_MD_STUB: &str = "\
# Agent Instructions
See config/agents.md for vault instructions.
";
/// Seed `config/agents.md` if missing or empty (idempotent, per-file).
/// Also seeds `type/config.md` for sidebar visibility.
pub fn seed_config_files(vault_path: &str) {
let vault = Path::new(vault_path);
let config_dir = vault.join("config");
if fs::create_dir_all(&config_dir).is_err() {
return;
}
let agents_path = config_dir.join("agents.md");
let needs_write =
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
if needs_write {
let _ = fs::write(&agents_path, AGENTS_MD);
log::info!("Seeded config/agents.md");
}
ensure_config_type_definition(vault_path);
}
/// Ensure `type/config.md` exists (gives Config type a sidebar icon/color).
fn ensure_config_type_definition(vault_path: &str) {
let type_dir = Path::new(vault_path).join("type");
if fs::create_dir_all(&type_dir).is_err() {
return;
}
let path = type_dir.join("config.md");
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
let _ = fs::write(&path, CONFIG_TYPE_DEFINITION);
}
}
/// Migrate root `AGENTS.md` → `config/agents.md` for existing vaults.
///
/// - If root `AGENTS.md` exists and `config/agents.md` does not: move content, write stub.
/// - If root `AGENTS.md` exists and `config/agents.md` also exists: just replace root with stub.
/// - If root `AGENTS.md` doesn't exist: write the stub anyway (for Codex discoverability).
///
/// Always idempotent and silent.
pub fn migrate_agents_md(vault_path: &str) {
let vault = Path::new(vault_path);
let root_agents = vault.join("AGENTS.md");
let config_dir = vault.join("config");
let config_agents = config_dir.join("agents.md");
// Ensure config/ directory exists
if fs::create_dir_all(&config_dir).is_err() {
return;
}
// If root AGENTS.md has real content (not already a stub), migrate it
if root_agents.exists() {
let content = fs::read_to_string(&root_agents).unwrap_or_default();
let is_stub = content.contains("See config/agents.md");
if !is_stub {
// Only move content if config/agents.md doesn't exist yet
let config_needs_write = !config_agents.exists()
|| fs::metadata(&config_agents).map_or(true, |m| m.len() == 0);
if config_needs_write {
let _ = fs::write(&config_agents, &content);
log::info!("Migrated AGENTS.md content to config/agents.md");
}
// Replace root with stub
let _ = fs::write(&root_agents, AGENTS_MD_STUB);
log::info!("Replaced root AGENTS.md with stub pointing to config/agents.md");
}
} else {
// No root AGENTS.md — write stub for Codex discoverability
let _ = fs::write(&root_agents, AGENTS_MD_STUB);
}
}
/// Repair config files: re-create missing `config/agents.md` and `type/config.md`.
/// Called by the "Repair Vault" command. Returns a status message.
pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Ensure config/ directory
let config_dir = vault.join("config");
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create config directory: {e}"))?;
let agents_path = config_dir.join("agents.md");
let root_agents = vault.join("AGENTS.md");
// Step 1: Migrate root AGENTS.md content → config/agents.md if needed
if root_agents.exists() {
let root_content = fs::read_to_string(&root_agents).unwrap_or_default();
let is_stub = root_content.contains("See config/agents.md");
if !is_stub && !root_content.is_empty() {
let config_needs_write =
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
if config_needs_write {
fs::write(&agents_path, &root_content)
.map_err(|e| format!("Failed to migrate AGENTS.md: {e}"))?;
}
fs::write(&root_agents, AGENTS_MD_STUB)
.map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?;
}
}
// Step 2: Seed config/agents.md with defaults if still missing or empty
let needs_write =
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&agents_path, AGENTS_MD)
.map_err(|e| format!("Failed to write config/agents.md: {e}"))?;
}
// Step 3: Ensure type/config.md
let type_dir = vault.join("type");
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
let config_type_path = type_dir.join("config.md");
let type_needs_write = !config_type_path.exists()
|| fs::metadata(&config_type_path).map_or(true, |m| m.len() == 0);
if type_needs_write {
fs::write(&config_type_path, CONFIG_TYPE_DEFINITION)
.map_err(|e| format!("Failed to write type/config.md: {e}"))?;
}
// Step 4: Ensure root AGENTS.md stub exists
let stub_needs_write = !root_agents.exists()
|| fs::read_to_string(&root_agents).map_or(true, |c| !c.contains("See config/agents.md"));
if stub_needs_write {
fs::write(&root_agents, AGENTS_MD_STUB)
.map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?;
}
Ok("Config files repaired".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_seed_config_files_creates_dir_and_agents() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("config").is_dir());
assert!(vault.join("config/agents.md").exists());
let content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
assert!(content.contains("Vault Instructions for AI Agents"));
}
#[test]
fn test_seed_config_files_creates_type_definition() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("type/config.md").exists());
let content = fs::read_to_string(vault.join("type/config.md")).unwrap();
assert!(content.contains("Is A: Type"));
assert!(content.contains("icon: gear-six"));
}
#[test]
fn test_seed_config_files_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
seed_config_files(vault.to_str().unwrap());
// Customize the file
let custom = "---\nIs A: Config\n---\n# Custom Agents\nMy custom instructions\n";
fs::write(vault.join("config/agents.md"), custom).unwrap();
seed_config_files(vault.to_str().unwrap());
let content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
assert!(
content.contains("Custom Agents"),
"must preserve existing content"
);
}
#[test]
fn test_seed_config_files_reseeds_empty() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let config_dir = vault.join("config");
fs::create_dir_all(&config_dir).unwrap();
fs::write(config_dir.join("agents.md"), "").unwrap();
seed_config_files(vault.to_str().unwrap());
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
assert!(content.contains("Vault Instructions for AI Agents"));
}
#[test]
fn test_migrate_agents_md_moves_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap();
migrate_agents_md(vault.to_str().unwrap());
// config/agents.md should have the original content
let config_content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
assert!(config_content.contains("Vault Instructions for AI Agents"));
// Root AGENTS.md should be a stub
let root_content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root_content.contains("See config/agents.md"));
assert!(!root_content.contains("## Structure"));
}
#[test]
fn test_migrate_agents_md_preserves_existing_config() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let config_dir = vault.join("config");
fs::create_dir_all(&config_dir).unwrap();
let custom = "# Custom agent instructions\n";
fs::write(config_dir.join("agents.md"), custom).unwrap();
fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap();
migrate_agents_md(vault.to_str().unwrap());
// config/agents.md should preserve custom content
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
assert!(content.contains("Custom agent instructions"));
// Root should be a stub
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root.contains("See config/agents.md"));
}
#[test]
fn test_migrate_agents_md_idempotent_on_stub() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("AGENTS.md"), AGENTS_MD_STUB).unwrap();
migrate_agents_md(vault.to_str().unwrap());
// Stub should remain unchanged
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root.contains("See config/agents.md"));
}
#[test]
fn test_migrate_agents_md_writes_stub_when_no_root() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
migrate_agents_md(vault.to_str().unwrap());
assert!(vault.join("AGENTS.md").exists());
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root.contains("See config/agents.md"));
}
#[test]
fn test_repair_config_files_creates_all() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
assert_eq!(msg, "Config files repaired");
assert!(vault.join("config/agents.md").exists());
assert!(vault.join("type/config.md").exists());
assert!(vault.join("AGENTS.md").exists());
let agents = fs::read_to_string(vault.join("config/agents.md")).unwrap();
assert!(agents.contains("Vault Instructions for AI Agents"));
let stub = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(stub.contains("See config/agents.md"));
}
#[test]
fn test_repair_config_files_preserves_custom_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let config_dir = vault.join("config");
fs::create_dir_all(&config_dir).unwrap();
let custom = "# My custom agent config\nDo not overwrite me\n";
fs::write(config_dir.join("agents.md"), custom).unwrap();
fs::write(
vault.join("AGENTS.md"),
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
)
.unwrap();
repair_config_files(vault.to_str().unwrap()).unwrap();
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
assert!(
content.contains("My custom agent config"),
"must preserve existing content"
);
}
#[test]
fn test_repair_config_files_migrates_root_agents() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let original = "# My vault agents instructions\nCustom content here\n";
fs::write(vault.join("AGENTS.md"), original).unwrap();
repair_config_files(vault.to_str().unwrap()).unwrap();
// Root should be a stub
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root.contains("See config/agents.md"));
// config/agents.md should have the original content
let config = fs::read_to_string(vault.join("config/agents.md")).unwrap();
assert!(config.contains("My vault agents instructions"));
}
}

View File

@@ -18,10 +18,10 @@ struct SampleFile {
content: &'static str,
}
/// Content for the AGENTS.md file written to the vault root.
/// Content for config/agents.md — vault instructions for AI agents.
/// This file has no YAML frontmatter — it is a convention file for AI agents,
/// not a vault note. The vault scanner will still pick it up as a regular entry.
const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
pub(super) const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph.
@@ -133,6 +133,14 @@ const SAMPLE_FILES: &[SampleFile] = &[
rel_path: "type/topic.md",
content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
},
SampleFile {
rel_path: "type/theme.md",
content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
},
SampleFile {
rel_path: "type/config.md",
content: "---\nIs A: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
},
SampleFile {
rel_path: "note/welcome-to-laputa.md",
content: r#"---
@@ -380,9 +388,19 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
// Write AGENTS.md at the vault root
fs::write(vault_dir.join("AGENTS.md"), AGENTS_MD)
.map_err(|e| format!("Failed to write AGENTS.md: {}", e))?;
// Write config/agents.md with vault instructions for AI agents
let config_dir = vault_dir.join("config");
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
fs::write(config_dir.join("agents.md"), AGENTS_MD)
.map_err(|e| format!("Failed to write config/agents.md: {}", e))?;
// Write root AGENTS.md stub for Codex discoverability
fs::write(
vault_dir.join("AGENTS.md"),
"# Agent Instructions\n\nSee config/agents.md for vault instructions.\n",
)
.map_err(|e| format!("Failed to write AGENTS.md stub: {}", e))?;
for sample in SAMPLE_FILES {
let file_path = vault_dir.join(sample.rel_path);
@@ -394,7 +412,7 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
}
// Seed built-in themes
// Seed built-in themes (both JSON legacy and vault-based markdown notes)
let themes_dir = vault_dir.join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
@@ -405,6 +423,25 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME)
.map_err(|e| format!("Failed to write minimal theme: {e}"))?;
let theme_notes_dir = vault_dir.join("theme");
fs::create_dir_all(&theme_notes_dir)
.map_err(|e| format!("Failed to create theme directory: {e}"))?;
fs::write(
theme_notes_dir.join("default.md"),
crate::theme::DEFAULT_VAULT_THEME,
)
.map_err(|e| format!("Failed to write default vault theme: {e}"))?;
fs::write(
theme_notes_dir.join("dark.md"),
crate::theme::DARK_VAULT_THEME,
)
.map_err(|e| format!("Failed to write dark vault theme: {e}"))?;
fs::write(
theme_notes_dir.join("minimal.md"),
crate::theme::MINIMAL_VAULT_THEME,
)
.map_err(|e| format!("Failed to write minimal vault theme: {e}"))?;
crate::git::init_repo(target_path)?;
Ok(vault_dir
@@ -439,6 +476,7 @@ mod tests {
assert!(result.is_ok());
// Verify key files exist
assert!(vault_path.join("config/agents.md").exists());
assert!(vault_path.join("AGENTS.md").exists());
assert!(vault_path.join("note/welcome-to-laputa.md").exists());
assert!(vault_path.join("note/editor-basics.md").exists());
@@ -453,6 +491,7 @@ mod tests {
assert!(vault_path.join("type/note.md").exists());
assert!(vault_path.join("type/person.md").exists());
assert!(vault_path.join("type/topic.md").exists());
assert!(vault_path.join("type/config.md").exists());
}
#[test]
@@ -507,57 +546,63 @@ mod tests {
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let entries = crate::vault::scan_vault(&vault_path).unwrap();
// SAMPLE_FILES + AGENTS.md
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1);
// SAMPLE_FILES + config/agents.md + AGENTS.md stub + 3 vault theme notes
assert_eq!(entries.len(), SAMPLE_FILES.len() + 2 + 3);
}
#[test]
fn test_agents_md_present_after_vault_creation() {
fn test_config_agents_md_present_after_vault_creation() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("agents-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let agents_path = vault_path.join("AGENTS.md");
assert!(agents_path.exists(), "AGENTS.md should exist at vault root");
let agents_path = vault_path.join("config/agents.md");
assert!(
agents_path.exists(),
"config/agents.md should exist in vault"
);
let content = fs::read_to_string(&agents_path).unwrap();
assert!(content.contains("Vault Instructions for AI Agents"));
assert!(content.contains("## Structure"));
assert!(content.contains("## Frontmatter"));
assert!(content.contains("## Wikilinks"));
assert!(content.contains("## Type definitions"));
assert!(content.contains("## Conventions"));
}
#[test]
fn test_root_agents_md_is_stub_after_vault_creation() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("stub-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let root_path = vault_path.join("AGENTS.md");
assert!(root_path.exists(), "Root AGENTS.md stub should exist");
let content = fs::read_to_string(&root_path).unwrap();
assert!(
content.contains("Vault Instructions for AI Agents"),
"AGENTS.md should contain instructions header"
content.contains("See config/agents.md"),
"Root AGENTS.md should redirect to config/agents.md"
);
assert!(
content.contains("## Structure"),
"AGENTS.md should describe vault structure"
);
assert!(
content.contains("## Frontmatter"),
"AGENTS.md should describe frontmatter"
);
assert!(
content.contains("## Wikilinks"),
"AGENTS.md should describe wikilinks"
);
assert!(
content.contains("## Type definitions"),
"AGENTS.md should describe type definitions"
);
assert!(
content.contains("## Conventions"),
"AGENTS.md should describe conventions"
!content.contains("## Structure"),
"Root AGENTS.md should not contain full instructions"
);
}
#[test]
fn test_agents_md_parseable_as_vault_entry() {
fn test_config_agents_md_parseable_as_vault_entry() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("agents-parse-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
let entry = crate::vault::parse_md_file(&vault_path.join("config/agents.md")).unwrap();
assert_eq!(
entry.title,
"AGENTS.md \u{2014} Vault Instructions for AI Agents"
);
assert_eq!(entry.is_a.as_deref(), Some("Config"));
}
#[test]
@@ -583,12 +628,21 @@ mod tests {
let vault_path = dir.path().join("theme-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
// JSON legacy themes
assert!(vault_path.join("_themes/default.json").exists());
assert!(vault_path.join("_themes/dark.json").exists());
assert!(vault_path.join("_themes/minimal.json").exists());
let themes = crate::theme::list_themes(vault_path.to_str().unwrap()).unwrap();
assert_eq!(themes.len(), 3);
// Vault-based theme notes
assert!(vault_path.join("theme/default.md").exists());
assert!(vault_path.join("theme/dark.md").exists());
assert!(vault_path.join("theme/minimal.md").exists());
// Theme type definition
assert!(vault_path.join("type/theme.md").exists());
}
#[test]

View File

@@ -1,4 +1,5 @@
mod cache;
mod config_seed;
mod getting_started;
mod image;
mod migration;
@@ -7,6 +8,7 @@ mod rename;
mod trash;
pub use cache::scan_vault_cached;
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
@@ -14,8 +16,8 @@ pub use rename::{rename_note, RenameResult};
pub use trash::{delete_note, purge_trash};
use parsing::{
capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet,
extract_title, parse_iso_date,
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
parse_iso_date, title_case_folder,
};
use gray_matter::engine::YAML;
@@ -74,6 +76,8 @@ pub struct VaultEntry {
/// 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>,
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
pub visible: Option<bool>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
@@ -128,6 +132,8 @@ struct Frontmatter {
sort: Option<String>,
#[serde(default)]
view: Option<String>,
#[serde(default)]
visible: Option<bool>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -175,6 +181,7 @@ const SKIP_KEYS: &[&str] = &[
"template",
"sort",
"view",
"visible",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -272,9 +279,10 @@ fn infer_type_from_folder(folder: &str) -> String {
"target" => "Target",
"journal" => "Journal",
"month" => "Month",
"config" => "Config",
"essay" => "Essay",
"evergreen" => "Evergreen",
_ => return capitalize_first(folder),
_ => return title_case_folder(folder),
}
.to_string()
}
@@ -401,6 +409,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
template: frontmatter.template,
sort: frontmatter.sort,
view: frontmatter.view,
visible: frontmatter.visible,
word_count,
outgoing_links,
properties,
@@ -960,6 +969,28 @@ References:
assert_eq!(entry.is_a, Some("Recipe".to_string()));
}
#[test]
fn test_infer_type_from_hyphenated_folder_title_cases() {
let dir = TempDir::new().unwrap();
let cases = vec![
("monday-ideas", "Monday Ideas"),
("key-result", "Key Result"),
("my_custom_type", "My Custom Type"),
("mix-and_match", "Mix And Match"),
];
for (folder, expected) in cases {
create_test_file(dir.path(), &format!("{}/test.md", folder), "# Test\n");
let entry = parse_md_file(&dir.path().join(folder).join("test.md")).unwrap();
assert_eq!(
entry.is_a,
Some(expected.to_string()),
"folder '{}' should infer type '{}'",
folder,
expected
);
}
}
#[test]
fn test_infer_type_frontmatter_overrides_folder() {
let dir = TempDir::new().unwrap();
@@ -1352,6 +1383,48 @@ Company: Acme Corp
assert!(entry.trashed_at.is_none());
}
// --- visible field tests ---
#[test]
fn test_parse_visible_false_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert_eq!(entry.visible, Some(false));
}
#[test]
fn test_parse_visible_true_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: true\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.visible, Some(true));
}
#[test]
fn test_parse_visible_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.visible, None);
}
#[test]
fn test_visible_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert!(entry.relationships.get("visible").is_none());
}
#[test]
fn test_visible_not_in_properties() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert!(entry.properties.get("visible").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

@@ -195,6 +195,16 @@ pub(super) fn capitalize_first(s: &str) -> String {
}
}
/// Title-case a folder name: split on hyphens and underscores, capitalize each word, join with spaces.
/// Example: "monday-ideas" → "Monday Ideas", "key_result" → "Key Result"
pub(super) fn title_case_folder(s: &str) -> String {
s.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(capitalize_first)
.collect::<Vec<_>>()
.join(" ")
}
/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch).
/// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats.
pub(super) fn parse_iso_date(date_str: &str) -> Option<u64> {
@@ -457,6 +467,29 @@ mod tests {
assert_eq!(capitalize_first("a"), "A");
}
// --- title_case_folder tests ---
#[test]
fn test_title_case_folder_hyphenated() {
assert_eq!(title_case_folder("monday-ideas"), "Monday Ideas");
assert_eq!(title_case_folder("key-result"), "Key Result");
}
#[test]
fn test_title_case_folder_underscored() {
assert_eq!(title_case_folder("my_custom_type"), "My Custom Type");
}
#[test]
fn test_title_case_folder_single_word() {
assert_eq!(title_case_folder("recipe"), "Recipe");
}
#[test]
fn test_title_case_folder_empty() {
assert_eq!(title_case_folder(""), "");
}
// --- without_h1_line tests ---
#[test]

View File

@@ -18,8 +18,6 @@ pub struct VaultConfig {
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";
@@ -100,15 +98,6 @@ fn serialize_config(config: &VaultConfig) -> String {
"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"
}
@@ -149,6 +138,108 @@ fn yaml_safe_value(value: &str) -> String {
}
}
/// Migrate `hidden_sections` from `config/ui.config.md` to `visible: false`
/// on Type notes. Returns the number of Type notes updated.
///
/// For each type name in `hidden_sections`:
/// - If `type/<slug>.md` exists, adds `visible: false` to its frontmatter
/// - If it doesn't exist, creates it with `type: Type`, `title: <name>`, `visible: false`
/// - Re-saves the config without `hidden_sections`
pub fn migrate_hidden_sections_to_visible(vault_path: &str) -> Result<usize, String> {
let path = config_path(vault_path);
if !path.exists() {
return Ok(0);
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
let hidden = extract_hidden_sections(&content);
if hidden.is_empty() {
return Ok(0);
}
let type_dir = Path::new(vault_path).join("type");
if !type_dir.exists() {
std::fs::create_dir_all(&type_dir)
.map_err(|e| format!("Failed to create type dir: {e}"))?;
}
let mut migrated = 0;
for type_name in &hidden {
let slug = type_name_to_slug(type_name);
let type_path = type_dir.join(format!("{slug}.md"));
if type_path.exists() {
let type_content = std::fs::read_to_string(&type_path)
.map_err(|e| format!("Failed to read {}: {e}", type_path.display()))?;
if !type_content.contains("visible:") {
let updated = crate::frontmatter::update_frontmatter_content(
&type_content,
"visible",
Some(crate::frontmatter::FrontmatterValue::Bool(false)),
)
.map_err(|e| format!("Failed to update {}: {e}", type_path.display()))?;
std::fs::write(&type_path, updated)
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
}
} else {
let new_content = format!(
"---\ntype: Type\ntitle: {}\nvisible: false\n---\n\n# {}\n",
type_name, type_name
);
std::fs::write(&type_path, new_content)
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
}
migrated += 1;
}
// Re-save config without hidden_sections
let config = parse_vault_config(&content)?;
save_vault_config(vault_path, config)?;
Ok(migrated)
}
/// Extract `hidden_sections` from raw YAML frontmatter.
fn extract_hidden_sections(content: &str) -> Vec<String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let hash = match parsed.data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return vec![],
};
match hash.get("hidden_sections") {
Some(gray_matter::Pod::Array(arr)) => arr
.iter()
.filter_map(|v| match v {
gray_matter::Pod::String(s) => Some(s.clone()),
_ => None,
})
.collect(),
_ => vec![],
}
}
/// Convert a Type name to a filesystem slug.
fn type_name_to_slug(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Convert gray_matter::Pod to serde_json::Value.
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {
@@ -194,9 +285,6 @@ status_colors:
Done: blue
property_display_modes:
deadline: date
hidden_sections:
- Archived
- Trash
---
"#;
let config = parse_vault_config(content).unwrap();
@@ -209,8 +297,6 @@ hidden_sections:
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]
@@ -223,14 +309,12 @@ hidden_sections:
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]
@@ -252,7 +336,6 @@ hidden_sections:
tag_colors: None,
status_colors: Some(status_colors),
property_display_modes: None,
hidden_sections: None,
};
save_vault_config(vault_path, config).unwrap();
@@ -264,6 +347,131 @@ hidden_sections:
);
}
#[test]
fn migrate_hidden_sections_creates_type_notes_with_visible_false() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Bookmark\n - Recipe\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 2);
// Check type notes were created
let bookmark = std::fs::read_to_string(dir.path().join("type/bookmark.md")).unwrap();
assert!(bookmark.contains("visible: false"));
assert!(bookmark.contains("title: Bookmark"));
let recipe = std::fs::read_to_string(dir.path().join("type/recipe.md")).unwrap();
assert!(recipe.contains("visible: false"));
// Config should no longer have hidden_sections
let config_content = std::fs::read_to_string(config_dir.join("ui.config.md")).unwrap();
assert!(!config_content.contains("hidden_sections"));
}
#[test]
fn migrate_hidden_sections_updates_existing_type_note() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Project\n---\n",
)
.unwrap();
// Create existing type note without visible
let type_dir = dir.path().join("type");
std::fs::create_dir_all(&type_dir).unwrap();
std::fs::write(
type_dir.join("project.md"),
"---\ntype: Type\ntitle: Project\nicon: briefcase\n---\n\n# Project\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(type_dir.join("project.md")).unwrap();
assert!(content.contains("visible: false"));
assert!(
content.contains("icon: briefcase"),
"should preserve existing fields"
);
}
#[test]
fn migrate_hidden_sections_skips_when_no_hidden_sections() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nzoom: 1.0\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrate_hidden_sections_skips_when_no_config_file() {
let dir = tempfile::TempDir::new().unwrap();
let count = migrate_hidden_sections_to_visible(dir.path().to_str().unwrap()).unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrate_hidden_sections_does_not_duplicate_visible() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Note\n---\n",
)
.unwrap();
// Type note already has visible: false
let type_dir = dir.path().join("type");
std::fs::create_dir_all(&type_dir).unwrap();
std::fs::write(
type_dir.join("note.md"),
"---\ntype: Type\ntitle: Note\nvisible: false\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(type_dir.join("note.md")).unwrap();
// Should have exactly one visible: false, not two
assert_eq!(content.matches("visible:").count(), 1);
}
#[test]
fn type_name_to_slug_converts_names() {
assert_eq!(type_name_to_slug("Project"), "project");
assert_eq!(type_name_to_slug("Weekly Review"), "weekly-review");
assert_eq!(type_name_to_slug("My Note!"), "my-note");
}
#[test]
fn yaml_safe_key_quoting() {
assert_eq!(yaml_safe_key("simple"), "simple");

View File

@@ -179,7 +179,7 @@ describe('App', () => {
await waitFor(() => {
// "All Notes" should be rendered as the selected nav item
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(screen.getByText('Favorites')).toBeInTheDocument()
expect(screen.getByText('Archive')).toBeInTheDocument()
})
})

View File

@@ -45,12 +45,15 @@ import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, VaultEntry } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries } from './utils/noteListHelpers'
import { openLocalFile } from './utils/url'
import './App.css'
// Type declaration for mock content storage
// Type declarations for mock content storage and test overrides
declare global {
interface Window {
__mockContent?: Record<string, string>
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map for Playwright test overrides
__mockHandlers?: Record<string, (args: any) => any>
}
}
@@ -110,10 +113,13 @@ function App() {
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const indexing = useIndexing(resolvedPath)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: vault.reloadVault,
onSyncUpdated: indexing.triggerIncrementalIndex,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names} — click to resolve`)
@@ -124,8 +130,6 @@ function App() {
// 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: () => {
@@ -229,19 +233,26 @@ function App() {
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
// O(1) path lookup map — rebuilt only when vault.entries changes
const entriesByPath = useMemo(() => {
const map = new Map<string, VaultEntry>()
for (const e of vault.entries) map.set(e.path, e)
return map
}, [vault.entries])
// 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}`)
const entry = entriesByPath.get(path) ?? entriesByPath.get(`${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}`)
const fresh = (freshEntries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
if (fresh) notes.handleSelectNote(fresh)
})
}
}, [vault, notes, resolvedPath])
}, [entriesByPath, vault, notes, resolvedPath])
const aiActivity = useAiActivity({
onOpenNote: openNoteByPath,
@@ -252,10 +263,18 @@ function App() {
onVaultChanged: () => { vault.reloadVault() },
})
// Stable callback for Pulse "open note" — never triggers reloadVault.
// Pulse files always exist in the vault; if somehow not found, silently skip.
const handlePulseOpenNote = useCallback((relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath)
if (entry) notes.handleSelectNote(entry)
}, [entriesByPath, resolvedPath, notes])
// 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}`)
const entry = (freshEntries as VaultEntry[]).find(e => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
if (entry) notes.handleSelectNote(entry)
})
}, [vault, notes, resolvedPath])
@@ -268,6 +287,10 @@ function App() {
}
}, [vault, notes, resolvedPath])
const handleAgentVaultChanged = useCallback(() => {
vault.reloadVault()
}, [vault])
const { triggerIncrementalIndex } = indexing
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
@@ -287,8 +310,13 @@ function App() {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = vault.entries.find(e => e.path === fullPath)
if (entry) {
// Markdown note — open inside Laputa editor
notes.handleSelectNote(entry)
dialogs.closeConflictResolver()
} else {
// Non-note file (e.g. .laputa-cache.json, settings.json) —
// open with system default app so the user can inspect/edit it
openLocalFile(fullPath)
}
}
}, [resolvedPath, vault.entries, notes, dialogs])
@@ -309,6 +337,7 @@ function App() {
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
createTypeEntry: notes.createTypeEntrySilent,
onFrontmatterPersisted: vault.loadModifiedFiles,
})
const handleDeleteNote = useCallback(async (path: string) => {
@@ -387,6 +416,19 @@ function App() {
}
}, [resolvedPath, vault, themeManager, setToastMessage])
const handleRepairVault = useCallback(async () => {
if (!resolvedPath) return
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
await vault.reloadVault()
await themeManager.reloadThemes()
setToastMessage(msg)
} catch (err) {
setToastMessage(`Failed to repair vault: ${err}`)
}
}, [resolvedPath, vault, themeManager, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
@@ -442,6 +484,8 @@ function App() {
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
onReindexVault: indexing.triggerFullReindex,
onRepairVault: handleRepairVault,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
@@ -494,7 +538,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
<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} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -503,11 +547,7 @@ function App() {
<>
<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')} />
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} 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} />
)}
@@ -563,11 +603,12 @@ function App() {
isDarkTheme={themeManager.isDark}
onFileCreated={handleAgentFileCreated}
onFileModified={handleAgentFileModified}
onVaultChanged={handleAgentVaultChanged}
/>
</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} 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} />
<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} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} 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} />

View File

@@ -18,6 +18,7 @@ interface AIChatPanelProps {
allContent: Record<string, string>
entries?: VaultEntry[]
onClose: () => void
onNavigateWikilink?: (target: string) => void
}
function TypingIndicator() {
@@ -99,10 +100,10 @@ function ContextSearchDropdown({
)
}
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) {
return (
<div>
<MarkdownContent content={msg.content} />
<MarkdownContent content={msg.content} onWikilinkClick={onNavigateWikilink} />
<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)}>
@@ -121,10 +122,10 @@ function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => v
)
}
function StreamingContent({ content }: { content: string }) {
function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) {
return (
<div style={{ marginBottom: 12 }}>
<MarkdownContent content={content} />
<MarkdownContent content={content} onWikilinkClick={onNavigateWikilink} />
</div>
)
}
@@ -176,7 +177,7 @@ function useContextNotes(entry: VaultEntry | null) {
// --- Main component ---
export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) {
export function AIChatPanel({ entry, allContent, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
const [input, setInput] = useState('')
const [showSearch, setShowSearch] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -213,7 +214,7 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
<MessageList
messages={chat.messages} isStreaming={chat.isStreaming}
streamingContent={chat.streamingContent} onRetry={chat.retryMessage}
messagesEndRef={messagesEndRef}
messagesEndRef={messagesEndRef} onNavigateWikilink={onNavigateWikilink}
/>
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
@@ -284,10 +285,11 @@ function ContextBar({
}
function MessageList({
messages, isStreaming, streamingContent, onRetry, messagesEndRef,
messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink,
}: {
messages: ChatMessage[]; isStreaming: boolean; streamingContent: string
onRetry: (idx: number) => void; messagesEndRef: React.RefObject<HTMLDivElement | null>
onNavigateWikilink?: (target: string) => void
}) {
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
@@ -302,10 +304,10 @@ function MessageList({
<div key={msg.id} style={{ marginBottom: 12 }}>
{msg.role === 'user'
? <UserBubble content={msg.content} />
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} />}
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />}
</div>
))}
{isStreaming && streamingContent && <StreamingContent content={streamingContent} />}
{isStreaming && streamingContent && <StreamingContent content={streamingContent} onNavigateWikilink={onNavigateWikilink} />}
{isStreaming && !streamingContent && <TypingIndicator />}
<div ref={messagesEndRef} />
</div>

View File

@@ -24,6 +24,7 @@ export interface AiMessageProps {
response?: string
isStreaming?: boolean
onOpenNote?: (path: string) => void
onNavigateWikilink?: (target: string) => void
}
function ReferencePill({ reference, onClick }: {
@@ -147,10 +148,10 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
)
}
function ResponseBlock({ text }: { text: string }) {
function ResponseBlock({ text, onNavigateWikilink }: { text: string; onNavigateWikilink?: (target: string) => void }) {
return (
<div style={{ marginBottom: 4 }}>
<MarkdownContent content={text} />
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
<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 }}
@@ -175,7 +176,7 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
@@ -212,7 +213,7 @@ export function AiMessage({ userMessage, references, reasoning, reasoningDone, a
onToggleExpand={toggleAction}
/>
)}
{response && <ResponseBlock text={response} />}
{response && <ResponseBlock text={response} onNavigateWikilink={onNavigateWikilink} />}
{isStreaming && !response && <StreamingIndicator />}
</div>
)

View File

@@ -4,10 +4,12 @@ import { AiPanel } from './AiPanel'
import type { VaultEntry } from '../types'
// Mock the hooks and utils to isolate component tests
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
vi.mock('../hooks/useAiAgent', () => ({
useAiAgent: () => ({
messages: [],
status: 'idle',
messages: mockMessages,
status: mockStatus,
sendMessage: vi.fn(),
clearConversation: vi.fn(),
}),
@@ -45,6 +47,11 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
})
describe('AiPanel', () => {
beforeEach(() => {
mockMessages = []
mockStatus = 'idle'
})
it('renders panel with AI Chat header', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.getByText('AI Chat')).toBeTruthy()
@@ -162,4 +169,41 @@ describe('AiPanel', () => {
fireEvent.keyDown(panel, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('clicking a wikilink in AI response calls onOpenNote with the target', () => {
mockMessages = [{
userMessage: 'Tell me about notes',
actions: [],
response: 'Check out [[Build Laputa App]] for details.',
id: 'msg-1',
}]
const onOpenNote = vi.fn()
const { container } = render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
)
const wikilink = container.querySelector('.chat-wikilink')
expect(wikilink).toBeTruthy()
expect(wikilink!.textContent).toBe('Build Laputa App')
fireEvent.click(wikilink!)
expect(onOpenNote).toHaveBeenCalledWith('Build Laputa App')
})
it('renders wikilinks with special characters and clicking works', () => {
mockMessages = [{
userMessage: 'Tell me about meetings',
actions: [],
response: 'See [[Meeting — 2024/01/15]] and [[Pasta Carbonara]].',
id: 'msg-2',
}]
const onOpenNote = vi.fn()
const { container } = render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
)
const wikilinks = container.querySelectorAll('.chat-wikilink')
expect(wikilinks).toHaveLength(2)
fireEvent.click(wikilinks[0])
expect(onOpenNote).toHaveBeenCalledWith('Meeting — 2024/01/15')
fireEvent.click(wikilinks[1])
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
})
})

View File

@@ -13,8 +13,11 @@ interface AiPanelProps {
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
vaultPath: string
activeEntry?: VaultEntry | null
/** Direct content of the active note from the editor tab. */
activeNoteContent?: string | null
entries?: VaultEntry[]
allContent?: Record<string, string>
openTabs?: VaultEntry[]
@@ -89,8 +92,8 @@ function EmptyState({ hasContext }: { hasContext: boolean }) {
)
}
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, hasContext }: {
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; onNavigateWikilink?: (target: string) => void; hasContext: boolean
}) {
const endRef = useRef<HTMLDivElement>(null)
@@ -102,14 +105,14 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
{messages.map((msg, i) => (
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} onNavigateWikilink={onNavigateWikilink} />
))}
<div ref={endRef} />
</div>
)
}
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
@@ -121,22 +124,24 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
}, [activeEntry, entries])
const contextPrompt = useMemo(() => {
if (!activeEntry || !allContent || !entries) return undefined
if (!activeEntry || !entries) return undefined
return buildContextSnapshot({
activeEntry,
allContent,
allContent: allContent ?? {},
activeNoteContent: activeNoteContent ?? undefined,
openTabs,
noteList,
noteListFilter,
entries,
references: pendingRefs.length > 0 ? pendingRefs : undefined,
})
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
}, [activeEntry, activeNoteContent, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
}), [onFileCreated, onFileModified])
onVaultChanged,
}), [onFileCreated, onFileModified, onVaultChanged])
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
const hasContext = !!activeEntry
@@ -167,6 +172,10 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
return () => window.removeEventListener('keydown', handleEscape)
}, [handleEscape])
const handleNavigateWikilink = useCallback((target: string) => {
onOpenNote?.(target)
}, [onOpenNote])
const handleSend = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return
setPendingRefs(references)
@@ -198,6 +207,7 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
messages={agent.messages}
isActive={isActive}
onOpenNote={onOpenNote}
onNavigateWikilink={handleNavigateWikilink}
hasContext={hasContext}
/>
<div

View File

@@ -837,7 +837,7 @@ describe('DynamicPropertiesPanel', () => {
beforeEach(() => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
initDisplayModeOverrides({})

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback, memo } from 'react'
import { useRef, useEffect, useCallback, memo, useMemo } from 'react'
import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap'
import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync'
import { useCreateBlockNote } from '@blocknote/react'
@@ -15,6 +15,7 @@ import { useEditorFocus } from '../hooks/useEditorFocus'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { schema } from './editorSchema'
import { mergeTabContent } from '../utils/mergeTabContent'
import './Editor.css'
import './EditorTheme.css'
@@ -73,6 +74,7 @@ interface EditorProps {
diffToggleRef?: React.MutableRefObject<() => void>
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}
function useEditorModeExclusion({
@@ -131,6 +133,7 @@ export const Editor = memo(function Editor({
diffToggleRef,
onFileCreated,
onFileModified,
onVaultChanged,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -170,6 +173,11 @@ export const Editor = memo(function Editor({
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
})
const enrichedAllContent = useMemo(
() => mergeTabContent(allContent, tabs),
[allContent, tabs],
)
const isLoadingNewTab = activeTabPath !== null && !activeTab
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
@@ -232,7 +240,7 @@ export const Editor = memo(function Editor({
inspectorEntry={inspectorEntry}
inspectorContent={inspectorContent}
entries={entries}
allContent={allContent}
allContent={enrichedAllContent}
gitHistory={gitHistory}
vaultPath={vaultPath ?? ''}
openTabs={tabs.map(t => t.entry)}
@@ -248,6 +256,7 @@ export const Editor = memo(function Editor({
onOpenNote={onNavigateWikilink}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
/>
</div>
</div>

View File

@@ -26,6 +26,7 @@ interface EditorRightPanelProps {
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
}
export function EditorRightPanel({
@@ -34,7 +35,7 @@ export function EditorRightPanel({
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
onFileCreated, onFileModified,
onFileCreated, onFileModified, onVaultChanged,
}: EditorRightPanelProps) {
if (showAIChat) {
return (
@@ -47,8 +48,10 @@ export function EditorRightPanel({
onOpenNote={onOpenNote}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
onVaultChanged={onVaultChanged}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
activeNoteContent={inspectorContent}
entries={entries}
allContent={allContent}
openTabs={openTabs}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { MarkdownContent } from './MarkdownContent'
import { preprocessWikilinks } from '../utils/chatWikilinks'
describe('MarkdownContent', () => {
it('renders bold text', () => {
@@ -72,4 +73,110 @@ describe('MarkdownContent', () => {
expect(bq).toBeTruthy()
expect(bq!.textContent).toContain('A quote')
})
describe('wikilinks', () => {
it('preprocessWikilinks converts [[Target]] to markdown links', () => {
expect(preprocessWikilinks('See [[My Note]]')).toBe('See [My Note](wikilink://My%20Note)')
expect(preprocessWikilinks('[[A]] and [[B]]')).toBe('[A](wikilink://A) and [B](wikilink://B)')
expect(preprocessWikilinks('`[[code]]`')).toBe('`[[code]]`')
})
it('renders [[Note Title]] as a clickable wikilink chip', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="Check out [[My Note]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')
expect(wikilink).toBeTruthy()
expect(wikilink!.textContent).toBe('My Note')
expect(wikilink!.getAttribute('data-wikilink-target')).toBe('My Note')
})
it('fires onWikilinkClick when a wikilink is clicked', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[Daily Log]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
fireEvent.click(wikilink)
expect(onClick).toHaveBeenCalledWith('Daily Log')
})
it('renders multiple wikilinks in the same paragraph', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[Note A]] and [[Note B]]" onWikilinkClick={onClick} />,
)
const wikilinks = container.querySelectorAll('.chat-wikilink')
expect(wikilinks).toHaveLength(2)
expect(wikilinks[0].textContent).toBe('Note A')
expect(wikilinks[1].textContent).toBe('Note B')
})
it('handles pipe syntax [[target|display]]', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[path/to/note|My Display]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
expect(wikilink.textContent).toBe('My Display')
expect(wikilink.getAttribute('data-wikilink-target')).toBe('path/to/note')
fireEvent.click(wikilink)
expect(onClick).toHaveBeenCalledWith('path/to/note')
})
it('does not render wikilinks inside inline code', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="Use `[[Not a link]]` syntax" onWikilinkClick={onClick} />,
)
expect(container.querySelector('.chat-wikilink')).toBeNull()
})
it('does not render wikilinks inside code blocks', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content={'```\n[[Not a link]]\n```'} onWikilinkClick={onClick} />,
)
expect(container.querySelector('.chat-wikilink')).toBeNull()
})
it('handles notes with special characters in title', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="Check [[Meeting — 2024/01/15]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
expect(wikilink.textContent).toBe('Meeting — 2024/01/15')
fireEvent.click(wikilink)
expect(onClick).toHaveBeenCalledWith('Meeting — 2024/01/15')
})
it('does not transform wikilinks when onWikilinkClick is not provided', () => {
const { container } = render(
<MarkdownContent content="See [[Some Note]]" />,
)
expect(container.querySelector('.chat-wikilink')).toBeNull()
expect(container.textContent).toContain('[[Some Note]]')
})
it('renders wikilinks inside list items', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content={'- First [[Note A]]\n- Second [[Note B]]'} onWikilinkClick={onClick} />,
)
const wikilinks = container.querySelectorAll('.chat-wikilink')
expect(wikilinks).toHaveLength(2)
})
it('has role="link" and tabIndex for accessibility', () => {
const onClick = vi.fn()
const { container } = render(
<MarkdownContent content="See [[Accessible Note]]" onWikilinkClick={onClick} />,
)
const wikilink = container.querySelector('.chat-wikilink')!
expect(wikilink.getAttribute('role')).toBe('link')
expect(wikilink.getAttribute('tabindex')).toBe('0')
})
})
})

View File

@@ -1,18 +1,63 @@
import { memo, useMemo } from 'react'
import Markdown from 'react-markdown'
import { memo, useMemo, useCallback, type MouseEvent } from 'react'
import Markdown, { defaultUrlTransform } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { preprocessWikilinks, WIKILINK_SCHEME } from '../utils/chatWikilinks'
const REMARK_PLUGINS = [remarkGfm]
const REHYPE_PLUGINS = [rehypeHighlight]
export const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) {
const rendered = useMemo(() => (
<div className="ai-markdown">
<Markdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS}>
{content}
function wikilinkUrlTransform(url: string): string {
if (url.startsWith(WIKILINK_SCHEME)) return url
return defaultUrlTransform(url)
}
interface MarkdownContentProps {
content: string
onWikilinkClick?: (target: string) => void
}
export const MarkdownContent = memo(function MarkdownContent({ content, onWikilinkClick }: MarkdownContentProps) {
const processedContent = useMemo(
() => onWikilinkClick ? preprocessWikilinks(content) : content,
[content, onWikilinkClick],
)
const handleClick = useCallback((e: MouseEvent) => {
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-wikilink-target]')
if (el) {
e.preventDefault()
onWikilinkClick?.(el.dataset.wikilinkTarget!)
}
}, [onWikilinkClick])
const components = useMemo(() => {
if (!onWikilinkClick) return undefined
return {
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
if (href?.startsWith(WIKILINK_SCHEME)) {
const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length))
return (
<span className="chat-wikilink" data-wikilink-target={target} role="link" tabIndex={0}>
{children}
</span>
)
}
return <a href={href}>{children}</a>
},
}
}, [onWikilinkClick])
return (
<div className="ai-markdown" onClick={onWikilinkClick ? handleClick : undefined} role="presentation">
<Markdown
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={components}
urlTransform={onWikilinkClick ? wikilinkUrlTransform : undefined}
>
{processedContent}
</Markdown>
</div>
), [content])
return rendered
)
})

View File

@@ -104,11 +104,17 @@ describe('PulseView', () => {
expect(nonLink.tagName).toBe('SPAN')
})
it('renders file list with correct titles', async () => {
it('renders file list with correct titles when expanded', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
// Files are collapsed by default — expand all commit cards first
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
screen.getAllByLabelText('Expand files').forEach((btn) => fireEvent.click(btn))
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
@@ -122,6 +128,12 @@ describe('PulseView', () => {
render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)
// Expand first commit card
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
fireEvent.click(screen.getAllByLabelText('Expand files')[0])
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
@@ -136,6 +148,12 @@ describe('PulseView', () => {
render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)
// Expand all commit cards to find the deleted file
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
screen.getAllByLabelText('Expand files').forEach((btn) => fireEvent.click(btn))
await waitFor(() => {
expect(screen.getByText('old')).toBeInTheDocument()
})
@@ -165,19 +183,13 @@ describe('PulseView', () => {
expect(screen.getByText('Retry')).toBeInTheDocument()
})
it('shows Load more button when hasMore is true', async () => {
const manyCommits = Array.from({ length: 30 }, (_, i) => ({
...mockCommits[0],
hash: `hash${i}`,
shortHash: `h${i}`,
message: `Commit ${i}`,
}))
mockInvokeFn.mockResolvedValue(manyCommits)
it('calls get_vault_pulse with skip=0 on initial load and passes correct page size', async () => {
mockInvokeFn.mockResolvedValue([])
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('Load more')).toBeInTheDocument()
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/test/vault', limit: 20, skip: 0 })
})
})
@@ -187,24 +199,32 @@ describe('PulseView', () => {
render(<PulseView vaultPath="/my/vault" />)
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 30 })
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 20, skip: 0 })
})
})
it('toggles file list visibility when clicking collapse button', async () => {
it('toggles file list visibility when clicking expand/collapse button', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
// Files are collapsed by default
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
expect(screen.queryByText('my project')).not.toBeInTheDocument()
// Click expand on first commit card
fireEvent.click(screen.getAllByLabelText('Expand files')[0])
// Files should now be visible
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
// Click the collapse button on the first commit card
const collapseBtn = screen.getAllByLabelText('Collapse files')[0]
fireEvent.click(collapseBtn)
// Click collapse to hide again
fireEvent.click(screen.getAllByLabelText('Collapse files')[0])
// Files should be hidden
expect(screen.queryByText('my project')).not.toBeInTheDocument()
})

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, memo } from 'react'
import { useState, useEffect, useCallback, useRef, memo } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { PulseCommit, PulseFile } from '../types'
@@ -96,7 +96,7 @@ function FileItem({ file, onOpenNote }: { file: PulseFile; onOpenNote?: (path: s
}
function CommitCard({ commit, onOpenNote }: { commit: PulseCommit; onOpenNote?: (path: string) => void }) {
const [expanded, setExpanded] = useState(true)
const [expanded, setExpanded] = useState(false)
const Chevron = expanded ? CaretDown : CaretRight
return (
@@ -207,20 +207,29 @@ function ErrorState({ message, onRetry }: { message: string; onRetry: () => void
)
}
const PAGE_SIZE = 20
export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar }: PulseViewProps) {
const [commits, setCommits] = useState<PulseCommit[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasMore, setHasMore] = useState(true)
const batchSize = 30
const [skip, setSkip] = useState(0)
const sentinelRef = useRef<HTMLDivElement | null>(null)
const loadPulse = useCallback(async (limit: number) => {
// Initial load
const loadInitial = useCallback(async () => {
setLoading(true)
setError(null)
setCommits([])
setSkip(0)
setHasMore(true)
try {
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit })
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip: 0 })
setCommits(result)
setHasMore(result.length >= limit)
setHasMore(result.length >= PAGE_SIZE)
setSkip(result.length)
} catch (err) {
const msg = typeof err === 'string' ? err : 'Failed to load activity'
setError(msg)
@@ -229,17 +238,40 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
}
}, [vaultPath])
useEffect(() => { loadPulse(batchSize) }, [loadPulse])
// Append next page
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore) return
setLoadingMore(true)
try {
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip })
setCommits((prev) => [...prev, ...result])
setHasMore(result.length >= PAGE_SIZE)
setSkip((s) => s + result.length)
} catch {
// silently fail for pagination — user can scroll up/retry
} finally {
setLoadingMore(false)
}
}, [vaultPath, skip, loadingMore, hasMore])
const handleLoadMore = useCallback(() => {
const nextLimit = commits.length + batchSize
loadPulse(nextLimit)
}, [commits.length, loadPulse])
useEffect(() => { loadInitial() }, [loadInitial])
// Intersection Observer for infinite scroll
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) loadMore() },
{ threshold: 0.1 },
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [loadMore])
const dayGroups = groupCommitsByDay(commits)
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
<div className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-background">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-border" style={{ height: 52, padding: '0 16px' }}>
<div className="flex items-center" style={{ gap: 8 }}>
@@ -260,12 +292,12 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
{/* Feed */}
<div className="flex-1 overflow-y-auto">
{loading && commits.length === 0 ? (
{loading ? (
<div className="flex items-center justify-center" style={{ padding: 32 }}>
<span className="text-[13px] text-muted-foreground">Loading activity</span>
</div>
) : error ? (
<ErrorState message={error} onRetry={() => loadPulse(batchSize)} />
<ErrorState message={error} onRetry={loadInitial} />
) : commits.length === 0 ? (
<EmptyState />
) : (
@@ -278,15 +310,11 @@ export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sideba
onOpenNote={onOpenNote}
/>
))}
{hasMore && (
<div style={{ padding: '12px 16px' }}>
<button
className="flex w-full cursor-pointer items-center justify-center rounded border border-border bg-transparent py-2 text-[12px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={handleLoadMore}
disabled={loading}
>
{loading ? 'Loading' : 'Load more'}
</button>
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} style={{ height: 1 }} />
{loadingMore && (
<div className="flex items-center justify-center" style={{ padding: 12 }}>
<span className="text-[12px] text-muted-foreground">Loading</span>
</div>
)}
</>

View File

@@ -1,8 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { Sidebar } from './Sidebar'
import type { VaultEntry, SidebarSelection } from '../types'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
const mockEntries: VaultEntry[] = [
{
@@ -234,10 +233,10 @@ const mockEntries: VaultEntry[] = [
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
describe('Sidebar', () => {
it('renders top nav items (All Notes and Favorites)', () => {
it('renders top nav items (All Notes)', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(screen.getByText('Favorites')).toBeInTheDocument()
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
})
it('renders section group headers only for types present in entries', () => {
@@ -676,13 +675,96 @@ describe('Sidebar', () => {
})
})
describe('customize section visibility', () => {
beforeEach(() => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
vi.fn(),
)
describe('type visibility via visible property', () => {
const makeTypeEntry = (title: string, visible: boolean | null): VaultEntry => ({
path: `/vault/type/${title.toLowerCase()}.md`,
filename: `${title.toLowerCase()}.md`,
title,
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible,
outgoingLinks: [],
properties: {},
})
it('hides a section when its Type entry has visible: false', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', false),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('People')).not.toBeInTheDocument()
// Other sections should still be visible
expect(screen.getByText('Projects')).toBeInTheDocument()
})
it('shows a section when its Type entry has visible: true', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', true),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('People')).toBeInTheDocument()
})
it('shows a section when its Type entry has visible: null (default)', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', null),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('People')).toBeInTheDocument()
})
it('shows a section when there is no Type entry at all (default visible)', () => {
// mockEntries has Person instances but no Type entry for Person
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('People')).toBeInTheDocument()
})
it('hides multiple sections when their Type entries have visible: false', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', false),
makeTypeEntry('Event', false),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('People')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('Topics')).toBeInTheDocument()
})
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Project', false),
makeTypeEntry('Person', false),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
})
it('renders a "Customize sections" button', () => {
@@ -699,74 +781,12 @@ describe('Sidebar', () => {
expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument()
})
it('hides a section when its toggle is clicked off', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// People section header should be visible initially
expect(screen.getByText('People')).toBeInTheDocument()
// Open customize popover and toggle People off
it('calls onToggleTypeVisibility when toggling a section in the popover', () => {
const onToggleTypeVisibility = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onToggleTypeVisibility={onToggleTypeVisibility} />)
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle People'))
// People section header should be gone (use getAllByText to handle popover)
const peopleElements = screen.queryAllByText('People')
// Only the toggle label in the popover should remain, not the section header
expect(peopleElements.length).toBeLessThanOrEqual(1)
})
it('re-shows a section when its toggle is clicked on again', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// Hide People
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle People'))
// Show People again
fireEvent.click(screen.getByLabelText('Toggle People'))
// People section should be visible again — popover toggle + section header = 2 "People" texts
const peopleElements = screen.getAllByText('People')
expect(peopleElements.length).toBe(2)
})
it('persists hidden sections in vault config', () => {
const { unmount } = render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle Events'))
unmount()
// Verify vault config was updated
const stored = getVaultConfig().hidden_sections
expect(stored).toContain('Event')
})
it('restores hidden sections from vault config on mount', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: ['Person', 'Event'] },
vi.fn(),
)
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// People and Events section headers should be hidden
expect(screen.queryByText('People')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
// Other section headers should still be visible
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('Topics')).toBeInTheDocument()
})
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: ['Project', 'Person'] },
vi.fn(),
)
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// Top nav items still present
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(screen.getByText('Favorites')).toBeInTheDocument()
expect(onToggleTypeVisibility).toHaveBeenCalledWith('Person')
})
it('closes popover when clicking outside', () => {
@@ -893,4 +913,42 @@ describe('Sidebar', () => {
expect(screen.queryByRole('textbox', { name: 'Section name' })).not.toBeInTheDocument()
})
})
it('renders exactly one section for a hyphenated custom type like Monday Ideas', () => {
const entriesWithMondayIdeas: VaultEntry[] = [
...mockEntries,
{
path: '/vault/monday-ideas/standup-bingo.md',
filename: 'standup-bingo.md',
title: 'Standup Bingo',
isA: 'Monday Ideas',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: null,
fileSize: 310, snippet: '', wordCount: 120,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/monday-ideas/theme-days.md',
filename: 'theme-days.md',
title: 'Theme Days',
isA: 'Monday Ideas',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: null,
fileSize: 280, snippet: '', wordCount: 95,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
]
render(<Sidebar entries={entriesWithMondayIdeas} selection={defaultSelection} onSelect={() => {}} />)
// "Monday Ideas" pluralized → "Monday Ideases" (the pluralizeType function)
const mondaySections = screen.getAllByText(/Monday Ideas/i)
expect(mondaySections).toHaveLength(1)
})
})

View File

@@ -4,7 +4,6 @@ import { resolveIcon } from '../utils/iconRegistry'
import { buildTypeEntryMap } from '../utils/typeColors'
import { pluralizeType } from '../hooks/useCommandRegistry'
import { TypeCustomizePopover } from './TypeCustomizePopover'
import { useSectionVisibility } from '../hooks/useSectionVisibility'
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor,
useSensor, useSensors, type DragEndEvent,
@@ -14,8 +13,8 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
FileText, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
} from '@phosphor-icons/react'
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
import {
@@ -35,6 +34,7 @@ interface SidebarProps {
onUpdateTypeTemplate?: (typeName: string, template: string) => void
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
onRenameSection?: (typeName: string, label: string) => void
onToggleTypeVisibility?: (typeName: string) => void
modifiedCount?: number
onCommitPush?: () => void
onCollapse?: () => void
@@ -104,13 +104,13 @@ function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, Vault
})
}
function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => boolean) {
function useSidebarSections(entries: VaultEntry[]) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const allSectionGroups = useMemo(() => {
const sections = buildDynamicSections(entries, typeEntryMap)
return sortSections(sections, typeEntryMap)
}, [entries, typeEntryMap])
const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible])
const visibleSections = useMemo(() => allSectionGroups.filter((g) => typeEntryMap[g.type]?.visible !== false), [allSectionGroups, typeEntryMap])
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])
return { typeEntryMap, allSectionGroups, visibleSections, sectionIds }
}
@@ -271,6 +271,7 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
onToggleTypeVisibility,
modifiedCount = 0, onCommitPush, onCollapse, isGitVault = false,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
@@ -285,8 +286,10 @@ export const Sidebar = memo(function Sidebar({
const popoverRef = useRef<HTMLDivElement>(null)
const customizeRef = useRef<HTMLDivElement>(null)
const { toggleSection: toggleVisibility, isSectionVisible } = useSectionVisibility()
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries, isSectionVisible)
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries)
const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap])
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
const { activeCount, archivedCount, trashedCount } = useEntryCounts(entries)
const closeContextMenu = useCallback(() => { setContextMenuPos(null); setContextMenuType(null) }, [])
@@ -353,9 +356,7 @@ export const Sidebar = memo(function Sidebar({
{/* Top nav */}
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
<NavItem icon={Star} label="Favorites" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'favorites' })} onClick={() => onSelect({ kind: 'filter', filter: 'favorites' })} />
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
<NavItem icon={TagSimple} label="Untagged" disabled />
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
{modifiedCount > 0 && (
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { StatusBar } from './StatusBar'
import type { VaultOption } from './StatusBar'
import { formatIndexedElapsed } from '../utils/indexingHelpers'
vi.mock('../utils/url', async () => {
const actual = await vi.importActual('../utils/url')
@@ -394,4 +395,71 @@ describe('StatusBar', () => {
fireEvent.click(screen.getByTestId('status-mcp'))
expect(onInstallMcp).not.toHaveBeenCalled()
})
it('shows "Indexed just now" when lastIndexedTime is recent and phase is idle', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
lastIndexedTime={Date.now() - 5000}
/>
)
expect(screen.getByText(/Indexed just now/)).toBeInTheDocument()
expect(screen.getByTestId('status-indexed-time')).toBeInTheDocument()
})
it('calls onReindexVault when clicking the indexed time badge', () => {
const onReindexVault = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
lastIndexedTime={Date.now() - 5000}
onReindexVault={onReindexVault}
/>
)
fireEvent.click(screen.getByTestId('status-indexed-time'))
expect(onReindexVault).toHaveBeenCalledOnce()
})
it('hides indexed time badge when no lastIndexedTime', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
/>
)
expect(screen.queryByTestId('status-indexed-time')).not.toBeInTheDocument()
})
})
describe('formatIndexedElapsed', () => {
it('returns empty string for null', () => {
expect(formatIndexedElapsed(null)).toBe('')
})
it('returns "Indexed just now" for < 60s', () => {
expect(formatIndexedElapsed(Date.now() - 30_000)).toBe('Indexed just now')
})
it('returns minutes for < 60min', () => {
expect(formatIndexedElapsed(Date.now() - 5 * 60_000)).toBe('Indexed 5m ago')
})
it('returns hours for < 24h', () => {
expect(formatIndexedElapsed(Date.now() - 3 * 3600_000)).toBe('Indexed 3h ago')
})
it('returns days for >= 24h', () => {
expect(formatIndexedElapsed(Date.now() - 48 * 3600_000)).toBe('Indexed 2d ago')
})
})

View File

@@ -4,6 +4,7 @@ import type { LastCommitInfo, SyncStatus } from '../types'
import type { IndexingProgress } from '../hooks/useIndexing'
import type { McpStatus } from '../hooks/useMcpStatus'
import { openExternalUrl } from '../utils/url'
import { formatIndexedElapsed } from '../utils/indexingHelpers'
export interface VaultOption {
label: string
@@ -33,7 +34,9 @@ interface StatusBarProps {
buildNumber?: string
onCheckForUpdates?: () => void
indexingProgress?: IndexingProgress
lastIndexedTime?: number | null
onRetryIndexing?: () => void
onReindexVault?: () => void
onRemoveVault?: (path: string) => void
mcpStatus?: McpStatus
onInstallMcp?: () => void
@@ -245,8 +248,32 @@ const INDEXING_LABELS: Record<string, string> = {
unavailable: 'Search unavailable',
}
function IndexingBadge({ progress, onRetry }: { progress: IndexingProgress; onRetry?: () => void }) {
if (progress.phase === 'idle' || progress.phase === 'unavailable') return null
function IndexingBadge({ progress, lastIndexedTime, onRetry, onReindex }: { progress: IndexingProgress; lastIndexedTime?: number | null; onRetry?: () => void; onReindex?: () => void }) {
const isIdle = progress.phase === 'idle' || progress.phase === 'unavailable'
// When idle, show "Indexed Xm ago" if we have a timestamp
if (isIdle) {
if (!lastIndexedTime) return null
const elapsed = formatIndexedElapsed(lastIndexedTime)
if (!elapsed) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={onReindex ? 'button' : undefined}
onClick={onReindex}
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', cursor: onReindex ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title={onReindex ? 'Click to reindex vault' : undefined}
data-testid="status-indexed-time"
onMouseEnter={onReindex ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onReindex ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
>
<Search size={13} />{elapsed}
</span>
</>
)
}
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
const isActive = !progress.done
const isError = progress.phase === 'error'
@@ -329,7 +356,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, onRetryIndexing, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -355,7 +382,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
{indexingProgress && <IndexingBadge progress={indexingProgress} onRetry={onRetryIndexing} />}
{indexingProgress && <IndexingBadge progress={indexingProgress} lastIndexedTime={lastIndexedTime} onRetry={onRetryIndexing} onReindex={onReindexVault} />}
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>

171
src/hooks/useAIChat.test.ts Normal file
View File

@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
// Capture what streamClaudeChat receives
const streamClaudeChatMock = vi.fn<
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
>()
vi.mock('../utils/ai-chat', async () => {
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
return {
...actual,
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
streamClaudeChatMock(...args)
// Simulate async: emit text, then done
const callbacks = args[3]
setTimeout(() => {
callbacks.onText('mock response')
callbacks.onDone()
}, 10)
return Promise.resolve('')
},
}
})
import { useAIChat } from './useAIChat'
beforeEach(() => {
streamClaudeChatMock.mockClear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useAIChat', () => {
const emptyContent: Record<string, string> = {}
it('sends first message as raw text without history', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
act(() => { result.current.sendMessage('hello') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
// First message: raw text, no history wrapping, no session_id
expect(message).toBe('hello')
expect(sessionId).toBeUndefined()
})
it('embeds conversation history in second message', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// First exchange
act(() => { result.current.sendMessage('What is 2+2?') })
await act(async () => { vi.advanceTimersByTime(50) })
// Second message — should include history from first exchange
act(() => { result.current.sendMessage('What is that times 3?') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
const [message] = streamClaudeChatMock.mock.calls[1]
expect(message).toContain('<conversation_history>')
expect(message).toContain('What is 2+2?')
expect(message).toContain('mock response')
expect(message).toContain('What is that times 3?')
})
it('accumulates history across multiple exchanges', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Exchange 1
act(() => { result.current.sendMessage('Q1') })
await act(async () => { vi.advanceTimersByTime(50) })
// Exchange 2
act(() => { result.current.sendMessage('Q2') })
await act(async () => { vi.advanceTimersByTime(50) })
// Exchange 3
act(() => { result.current.sendMessage('Q3') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
// First call: no history
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
// Second call: history from first exchange
const secondMsg = streamClaudeChatMock.mock.calls[1][0]
expect(secondMsg).toContain('Q1')
expect(secondMsg).toContain('Q2')
// Third call: history from both exchanges
const thirdMsg = streamClaudeChatMock.mock.calls[2][0]
expect(thirdMsg).toContain('Q1')
expect(thirdMsg).toContain('Q2')
expect(thirdMsg).toContain('Q3')
})
it('never passes session_id (no --resume)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
act(() => { result.current.sendMessage('Q1') })
await act(async () => { vi.advanceTimersByTime(50) })
act(() => { result.current.sendMessage('Q2') })
// All calls should have undefined session_id
for (const call of streamClaudeChatMock.mock.calls) {
expect(call[2]).toBeUndefined()
}
})
it('resets history after clearConversation', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Build up some history
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Clear
act(() => { result.current.clearConversation() })
expect(result.current.messages).toHaveLength(0)
// Next message should have no history
act(() => { result.current.sendMessage('fresh start') })
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
expect(lastCall[0]).toBe('fresh start') // raw text, no history wrapping
expect(lastCall[2]).toBeUndefined()
})
it('includes system prompt on every message when context notes exist', async () => {
const content = { 'note.md': 'Some note content' }
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
const { result } = renderHook(() => useAIChat(content, notes))
// First message
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Second message
act(() => { result.current.sendMessage('follow up') })
// Both calls should have system prompt
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
expect(firstSystemPrompt).toBeTruthy()
expect(firstSystemPrompt).toContain('Test Note')
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
expect(secondSystemPrompt).toBeTruthy()
expect(secondSystemPrompt).toContain('Test Note')
})
it('retries with correct history (excludes retried exchange)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// First exchange
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
expect(result.current.messages).toHaveLength(2)
// Retry the assistant response (index 1)
act(() => { result.current.retryMessage(1) })
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
// Should re-send the user message with no history (retrying first exchange)
expect(lastCall[0]).toBe('hello')
expect(lastCall[2]).toBeUndefined()
})
})

View File

@@ -1,14 +1,55 @@
/**
* Custom hook encapsulating AI chat state and message handling.
* Uses Claude CLI subprocess via Tauri for streaming responses.
*
* Conversation continuity embeds prior exchanges in each prompt
* (each CLI invocation is a fresh subprocess with no memory).
* History is trimmed to MAX_HISTORY_TOKENS, dropping oldest first.
*/
import { useState, useCallback, useRef } from 'react'
import type { VaultEntry } from '../types'
import {
type ChatMessage, nextMessageId,
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
buildSystemPrompt, streamClaudeChat,
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
} from '../utils/ai-chat'
interface ChatStreamRefs {
abortRef: React.RefObject<boolean>
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
}
/** Create stream callbacks that accumulate text and update React state. */
function makeStreamCallbacks(
refs: ChatStreamRefs,
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
let accumulated = ''
const callbacks: ChatStreamCallbacks = {
onText: (chunk) => {
if (refs.abortRef.current) return
accumulated += chunk
refs.setStreamingContent(accumulated)
},
onError: (error) => {
if (refs.abortRef.current) return
refs.setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
refs.setStreamingContent('')
refs.setIsStreaming(false)
},
onDone: () => {
if (refs.abortRef.current) return
if (accumulated) {
refs.setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
}
refs.setStreamingContent('')
refs.setIsStreaming(false)
},
}
return { callbacks, getAccumulated: () => accumulated }
}
export function useAIChat(
allContent: Record<string, string>,
contextNotes: VaultEntry[],
@@ -17,55 +58,40 @@ export function useAIChat(
const [isStreaming, setIsStreaming] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const abortRef = useRef(false)
const sessionIdRef = useRef<string | undefined>(undefined)
const sendMessage = useCallback((text: string) => {
/** Internal: send text with explicit history context. */
const doSend = useCallback((text: string, history: ChatMessage[]) => {
if (!text.trim() || isStreaming) return
const userMsg: ChatMessage = { role: 'user', content: text.trim(), id: nextMessageId() }
setMessages(prev => [...prev, userMsg])
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
setIsStreaming(true)
setStreamingContent('')
abortRef.current = false
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
let accumulated = ''
// Always include system prompt (each request is a fresh subprocess).
const systemPrompt = buildSystemPrompt(contextNotes, allContent).prompt || undefined
streamClaudeChat(text.trim(), systemPrompt || undefined, sessionIdRef.current, {
onInit: (sid) => { sessionIdRef.current = sid },
// Embed conversation history in the prompt for continuity.
const trimmedHistory = trimHistory(history, MAX_HISTORY_TOKENS)
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
onText: (chunk) => {
if (abortRef.current) return
accumulated += chunk
setStreamingContent(accumulated)
},
onError: (error) => {
if (abortRef.current) return
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
setStreamingContent('')
setIsStreaming(false)
},
onDone: () => {
if (abortRef.current) return
if (accumulated) {
setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
}
setStreamingContent('')
setIsStreaming(false)
},
}).then(sid => {
if (sid) sessionIdRef.current = sid
const { callbacks } = makeStreamCallbacks({
abortRef, setMessages, setStreamingContent, setIsStreaming,
})
streamClaudeChat(formattedMessage, systemPrompt, undefined, callbacks)
.catch(() => { /* errors forwarded via onError */ })
}, [isStreaming, allContent, contextNotes])
const sendMessage = useCallback((text: string) => {
doSend(text, messages)
}, [doSend, messages])
const clearConversation = useCallback(() => {
abortRef.current = true
setMessages([])
setIsStreaming(false)
setStreamingContent('')
sessionIdRef.current = undefined
}, [])
const retryMessage = useCallback((msgIndex: number) => {
@@ -74,9 +100,10 @@ export function useAIChat(
const userMsg = messages[userMsgIndex]
if (userMsg.role !== 'user') return
setMessages(prev => prev.slice(0, msgIndex))
sendMessage(userMsg.content)
}, [messages, sendMessage])
const historyForRetry = messages.slice(0, userMsgIndex)
setMessages(prev => prev.slice(0, userMsgIndex))
doSend(userMsg.content, historyForRetry)
}, [messages, doSend])
return { messages, isStreaming, streamingContent, sendMessage, clearConversation, retryMessage }
}

View File

@@ -0,0 +1,178 @@
import { describe, it, expect, vi } from 'vitest'
import { detectFileOperation, parseBashFileCreation } from './useAiAgent'
import type { AgentFileCallbacks } from './useAiAgent'
const VAULT = '/Users/luca/Laputa'
function makeCallbacks() {
return {
onFileCreated: vi.fn(),
onFileModified: vi.fn(),
onVaultChanged: vi.fn(),
} satisfies AgentFileCallbacks
}
describe('detectFileOperation', () => {
it('calls onFileCreated for Write tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('calls onFileModified for Edit tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Edit', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileModified).toHaveBeenCalledWith('note/test.md')
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores non-md files', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/image.png` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores files outside vault', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: '/tmp/other.md' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores unknown tool names', () => {
const cb = makeCallbacks()
detectFileOperation('Read', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('calls onVaultChanged when Write input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Write', undefined, VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('calls onVaultChanged when Edit input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Edit', undefined, VAULT, cb)
expect(cb.onFileModified).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('calls onVaultChanged when Write has malformed JSON input', () => {
const cb = makeCallbacks()
detectFileOperation('Write', 'not-json', VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('does not call onVaultChanged when Write detects specific file', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('does not call onVaultChanged for Read tool', () => {
const cb = makeCallbacks()
detectFileOperation('Read', undefined, VAULT, cb)
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('calls onVaultChanged when Bash input is undefined', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', undefined, VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
expect(cb.onVaultChanged).toHaveBeenCalled()
})
it('handles undefined callbacks gracefully', () => {
expect(() => detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, undefined)).not.toThrow()
})
it('detects Bash redirect creating .md file in vault', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "# Note" > ${VAULT}/note/new.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/new.md')
})
it('detects Bash append redirect creating .md file', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `cat content.txt >> ${VAULT}/daily/2026-03-07.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('daily/2026-03-07.md')
})
it('detects Bash tee command creating .md file', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "content" | tee ${VAULT}/note/tee-note.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/tee-note.md')
})
it('ignores Bash commands without file creation', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: 'ls -la' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores Bash creating non-md files', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: `echo "data" > ${VAULT}/config.json` }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('ignores Bash creating .md files outside vault', () => {
const cb = makeCallbacks()
detectFileOperation('Bash', JSON.stringify({ command: 'echo "text" > /tmp/other.md' }), VAULT, cb)
expect(cb.onFileCreated).not.toHaveBeenCalled()
})
it('uses path field when file_path is missing', () => {
const cb = makeCallbacks()
detectFileOperation('Write', JSON.stringify({ path: `${VAULT}/note/alt.md` }), VAULT, cb)
expect(cb.onFileCreated).toHaveBeenCalledWith('note/alt.md')
})
})
describe('parseBashFileCreation', () => {
it('returns null for undefined input', () => {
expect(parseBashFileCreation(undefined, VAULT)).toBeNull()
})
it('returns null for malformed JSON', () => {
expect(parseBashFileCreation('not json', VAULT)).toBeNull()
})
it('returns null when no command field', () => {
expect(parseBashFileCreation(JSON.stringify({ other: 'value' }), VAULT)).toBeNull()
})
it('returns null when command is not a string', () => {
expect(parseBashFileCreation(JSON.stringify({ command: 42 }), VAULT)).toBeNull()
})
it('parses simple redirect', () => {
const input = JSON.stringify({ command: `echo "# Title" > ${VAULT}/note.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('note.md')
})
it('parses append redirect', () => {
const input = JSON.stringify({ command: `echo "line" >> ${VAULT}/sub/note.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('sub/note.md')
})
it('parses tee command', () => {
const input = JSON.stringify({ command: `echo "data" | tee ${VAULT}/new.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
})
it('parses tee -a (append) command', () => {
const input = JSON.stringify({ command: `echo "extra" | tee -a ${VAULT}/new.md` })
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
})
it('returns null for non-md redirect', () => {
const input = JSON.stringify({ command: `echo "x" > ${VAULT}/file.txt` })
expect(parseBashFileCreation(input, VAULT)).toBeNull()
})
})

View File

@@ -32,6 +32,8 @@ export interface AiAgentMessage {
export interface AgentFileCallbacks {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
/** Fallback: vault may have changed but we can't determine the specific file. */
onVaultChanged?: () => void
}
export function useAiAgent(
@@ -105,7 +107,10 @@ export function useAiAgent(
if (abortRef.current.aborted) return
markReasoningDone()
setStatus('tool-executing')
toolInputMapRef.current.set(toolId, { tool: toolName, input })
// Preserve accumulated input — tool_progress events arrive with
// input=undefined AFTER the assistant message set the full input.
const prev = toolInputMapRef.current.get(toolId)
toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? prev?.input })
update(m => {
const existing = m.actions.find(a => a.toolId === toolId)
if (existing) {
@@ -169,6 +174,8 @@ export function useAiAgent(
response: finalResponse,
actions: m.actions.map(a => a.status === 'pending' ? { ...a, status: 'done' as const } : a),
}))
// Safety net: refresh vault after agent completes in case file changes were missed
fileCallbacksRef.current?.onVaultChanged?.()
},
})
}, [status, vaultPath])
@@ -205,22 +212,53 @@ function toVaultRelative(filePath: string, vaultPath: string): string | null {
}
/** Detect file operations from completed tool calls and notify callbacks. */
function detectFileOperation(
export function detectFileOperation(
toolName: string,
input: string | undefined,
vaultPath: string,
callbacks: AgentFileCallbacks | undefined,
) {
if (!callbacks) return
// Handle Bash commands that create/write .md files
if (toolName === 'Bash') {
const mdPath = parseBashFileCreation(input, vaultPath)
if (mdPath) { callbacks.onFileCreated?.(mdPath); return }
// Bash ran but we couldn't detect a specific .md file — still may have changed vault
callbacks.onVaultChanged?.()
return
}
if (toolName !== 'Write' && toolName !== 'Edit') return
const filePath = parseFilePath(input)
if (!filePath || !filePath.endsWith('.md')) return
const rel = toVaultRelative(filePath, vaultPath)
if (!rel) return
if (toolName === 'Write') {
callbacks.onFileCreated?.(rel)
} else {
callbacks.onFileModified?.(rel)
if (filePath && filePath.endsWith('.md')) {
const rel = toVaultRelative(filePath, vaultPath)
if (rel) {
if (toolName === 'Write') callbacks.onFileCreated?.(rel)
else callbacks.onFileModified?.(rel)
return
}
}
// Write/Edit completed but couldn't determine target file — trigger vault refresh
callbacks.onVaultChanged?.()
}
/** Detect .md file creation from a Bash command string. */
export function parseBashFileCreation(input: string | undefined, vaultPath: string): string | null {
if (!input) return null
try {
const parsed = JSON.parse(input)
const cmd = parsed.command ?? parsed.cmd
if (typeof cmd !== 'string') return null
// Match redirect patterns: > file.md, >> file.md, tee file.md, cat > file.md
const match = cmd.match(/(?:>|>>|tee\s+(?:-a\s+)?)\s*["']?([^\s"'|;]+\.md)["']?/)
if (!match) return null
const filePath = match[1]
return toVaultRelative(filePath, vaultPath)
} catch {
return null
}
}

View File

@@ -66,6 +66,8 @@ interface AppCommandsConfig {
vaultCount?: number
mcpStatus?: string
onInstallMcp?: () => void
onReindexVault?: () => void
onRepairVault?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -86,7 +88,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
const { onSelect } = config
const selectFilter = useCallback((filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes' | 'pulse') => {
const selectFilter = useCallback((filter: 'all' | 'archived' | 'trash' | 'changes' | 'pulse') => {
onSelect({ kind: 'filter', filter })
}, [onSelect])
@@ -148,6 +150,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onResolveConflicts: config.onResolveConflicts,
onViewChanges: viewChanges,
onInstallMcp: config.onInstallMcp,
onReindexVault: config.onReindexVault,
onRepairVault: config.onRepairVault,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,
@@ -201,6 +205,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
vaultCount: config.vaultCount,
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
onReindexVault: config.onReindexVault,
onRepairVault: config.onRepairVault,
})
useKeyboardNavigation({

View File

@@ -255,6 +255,47 @@ describe('useAutoSync', () => {
expect(pullCalls).toHaveLength(0)
})
it('calls onSyncUpdated when pull has updates', async () => {
const onSyncUpdated = vi.fn()
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
return Promise.resolve(updated(['note.md']))
})
renderHook(() =>
useAutoSync({
vaultPath: '/Users/luca/Laputa',
intervalMinutes: 5,
onVaultUpdated,
onSyncUpdated,
onConflict,
onToast,
}),
)
await waitFor(() => {
expect(onSyncUpdated).toHaveBeenCalledOnce()
})
})
it('does not call onSyncUpdated when pull is up_to_date', async () => {
const onSyncUpdated = vi.fn()
renderHook(() =>
useAutoSync({
vaultPath: '/Users/luca/Laputa',
intervalMinutes: 5,
onVaultUpdated,
onSyncUpdated,
onConflict,
onToast,
}),
)
await waitFor(() => {
expect(onVaultUpdated).not.toHaveBeenCalled()
})
expect(onSyncUpdated).not.toHaveBeenCalled()
})
it('detects conflicts when git_pull returns error with unresolved conflicts', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_conflict_files') return Promise.resolve(['conflict.md'])

View File

@@ -13,6 +13,7 @@ interface UseAutoSyncOptions {
vaultPath: string
intervalMinutes: number | null
onVaultUpdated: () => void
onSyncUpdated?: () => void
onConflict: (files: string[]) => void
onToast: (msg: string) => void
}
@@ -33,6 +34,7 @@ export function useAutoSync({
vaultPath,
intervalMinutes,
onVaultUpdated,
onSyncUpdated,
onConflict,
onToast,
}: UseAutoSyncOptions): AutoSyncState {
@@ -42,8 +44,8 @@ export function useAutoSync({
const [lastCommitInfo, setLastCommitInfo] = useState<LastCommitInfo | null>(null)
const syncingRef = useRef(false)
const pauseRef = useRef(false)
const callbacksRef = useRef({ onVaultUpdated, onConflict, onToast })
callbacksRef.current = { onVaultUpdated, onConflict, onToast }
const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
const checkExistingConflicts = useCallback(async (): Promise<boolean> => {
@@ -77,6 +79,7 @@ export function useAutoSync({
setSyncStatus('idle')
setConflictFiles([])
callbacksRef.current.onVaultUpdated()
callbacksRef.current.onSyncUpdated?.()
callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
} else if (result.status === 'conflict') {
setSyncStatus('conflict')

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
const noop = () => {}
const noopCallbacks: CodeMirrorCallbacks = {
onDocChange: noop,
onCursorActivity: noop,
onSave: noop,
onEscape: () => false,
}
describe('useCodeMirror', () => {
let container: HTMLDivElement
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
document.body.removeChild(container)
})
it('creates an EditorView in the container', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello world', false, noopCallbacks),
)
expect(result.current.current).not.toBeNull()
expect(container.querySelector('.cm-editor')).toBeInTheDocument()
})
it('calls requestMeasure when laputa-zoom-change event fires', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello', false, noopCallbacks),
)
const view = result.current.current!
const spy = vi.spyOn(view, 'requestMeasure')
act(() => {
window.dispatchEvent(new Event('laputa-zoom-change'))
})
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
it('stops listening for zoom changes after unmount', () => {
const ref = { current: container }
const { result, unmount } = renderHook(() =>
useCodeMirror(ref, 'hello', false, noopCallbacks),
)
const view = result.current.current!
const spy = vi.spyOn(view, 'requestMeasure')
unmount()
act(() => {
window.dispatchEvent(new Event('laputa-zoom-change'))
})
// After unmount, the listener should be removed — requestMeasure should NOT be called.
// (The view is also destroyed on unmount, so this verifies cleanup.)
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
})

View File

@@ -111,7 +111,19 @@ export function useCodeMirror(
const view = new EditorView({ state, parent })
viewRef.current = view
return () => { view.destroy(); viewRef.current = null }
// When CSS zoom changes on the document, CodeMirror's cached measurements
// (scaleX/scaleY, line heights, character widths) become stale because
// ResizeObserver doesn't fire for ancestor zoom changes. Force a re-measure
// so cursor placement stays accurate at any zoom level.
const handleZoomChange = () => { view.requestMeasure() }
window.addEventListener('laputa-zoom-change', handleZoomChange)
return () => {
window.removeEventListener('laputa-zoom-change', handleZoomChange)
view.destroy()
viewRef.current = null
}
// Re-create editor when isDark changes (theme is baked into extensions)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDark])

View File

@@ -95,6 +95,46 @@ describe('useCommandRegistry', () => {
expect(cmd!.enabled).toBe(false)
})
it('includes reindex-vault command in Settings group', () => {
const config = makeConfig({ onReindexVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('Settings')
expect(cmd!.label).toBe('Reindex Vault')
})
it('reindex-vault is enabled when onReindexVault is provided', () => {
const config = makeConfig({ onReindexVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd!.enabled).toBe(true)
})
it('reindex-vault is disabled when onReindexVault is not provided', () => {
const config = makeConfig()
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd!.enabled).toBe(false)
})
it('reindex-vault executes onReindexVault callback', () => {
const onReindexVault = vi.fn()
const config = makeConfig({ onReindexVault })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
cmd!.execute()
expect(onReindexVault).toHaveBeenCalled()
})
it('reindex-vault has searchable keywords', () => {
const config = makeConfig({ onReindexVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd!.keywords).toContain('reindex')
expect(cmd!.keywords).toContain('search')
})
it('resolve-conflicts stays enabled across rerenders', () => {
const config = makeConfig()
const { result, rerender } = renderHook(
@@ -157,6 +197,64 @@ describe('groupSortKey', () => {
})
})
describe('install-mcp command', () => {
it('is enabled when mcpStatus is not_installed and handler provided', () => {
const config = makeConfig({ mcpStatus: 'not_installed', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd).toBeDefined()
expect(cmd!.enabled).toBe(true)
expect(cmd!.label).toBe('Install MCP Server')
})
it('is enabled when mcpStatus is installed and handler provided (restore use case)', () => {
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.enabled).toBe(true)
expect(cmd!.label).toBe('Restore MCP Server')
})
it('is enabled even when mcpStatus is checking', () => {
const config = makeConfig({ mcpStatus: 'checking', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.enabled).toBe(true)
})
it('is enabled even when no handler provided', () => {
const config = makeConfig({ mcpStatus: 'not_installed' })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.enabled).toBe(true)
})
it('has restore keyword for discoverability', () => {
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.keywords).toContain('restore')
expect(cmd!.keywords).toContain('mcp')
expect(cmd!.keywords).toContain('claude')
})
it('executes onInstallMcp callback', () => {
const onInstallMcp = vi.fn()
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
cmd!.execute()
expect(onInstallMcp).toHaveBeenCalled()
})
it('is in Settings group', () => {
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'install-mcp')
expect(cmd!.group).toBe('Settings')
})
})
describe('buildTypeCommands', () => {
it('creates new and list commands for each type', () => {
const onCreateNoteOfType = vi.fn()

View File

@@ -20,6 +20,8 @@ interface CommandRegistryConfig {
modifiedCount: number
mcpStatus?: string
onInstallMcp?: () => void
onReindexVault?: () => void
onRepairVault?: () => void
onQuickOpen: () => void
onCreateNote: () => void
@@ -196,6 +198,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onReindexVault,
onRepairVault,
} = config
const hasActiveNote = activeTabPath !== null
@@ -214,7 +218,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
// Navigation
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
{ id: 'go-favorites', label: 'Go to Favorites', group: 'Navigation', keywords: ['starred'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'favorites' }) },
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
@@ -257,7 +260,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
{ id: 'install-mcp', label: 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install'], enabled: mcpStatus === 'not_installed' && !!onInstallMcp, execute: () => onInstallMcp?.() },
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
{ id: 'reindex-vault', label: 'Reindex Vault', group: 'Settings', keywords: ['reindex', 'index', 'search', 'rebuild', 'refresh'], enabled: !!onReindexVault, execute: () => onReindexVault?.() },
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
@@ -276,5 +281,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onReindexVault, onRepairVault,
])
}

View File

@@ -25,8 +25,8 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
relationships: {},
icon: null,
color: null,
order: null,
template: null, sort: null,
order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
...overrides,
@@ -50,10 +50,13 @@ describe('useEntryActions', () => {
handleDeleteProperty,
setToastMessage,
createTypeEntry,
onFrontmatterPersisted,
})
)
}
const onFrontmatterPersisted = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
@@ -73,6 +76,7 @@ describe('useEntryActions', () => {
trashedAt: expect.any(Number),
})
expect(setToastMessage).toHaveBeenCalledWith('Note moved to trash')
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
})
})
@@ -91,6 +95,7 @@ describe('useEntryActions', () => {
trashedAt: null,
})
expect(setToastMessage).toHaveBeenCalledWith('Note restored from trash')
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
})
})
@@ -105,6 +110,7 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', true)
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: true })
expect(setToastMessage).toHaveBeenCalledWith('Note archived')
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
})
})
@@ -119,6 +125,7 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', false)
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: false })
expect(setToastMessage).toHaveBeenCalledWith('Note unarchived')
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
})
})
@@ -134,6 +141,7 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'green')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('auto-creates type entry when not found and applies customization', async () => {
@@ -167,50 +175,52 @@ describe('useEntryActions', () => {
})
describe('handleUpdateTypeTemplate', () => {
it('updates template on the type entry', () => {
it('updates template on the type entry', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeEntry])
act(() => {
result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
await act(async () => {
await result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '## Objective\n\n## Notes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: '## Objective\n\n## Notes' })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('sets template to null when empty string', () => {
it('sets template to null when empty string', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeEntry])
act(() => {
result.current.handleUpdateTypeTemplate('Project', '')
await act(async () => {
await result.current.handleUpdateTypeTemplate('Project', '')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: null })
})
it('does nothing when type entry not found', () => {
it('auto-creates type entry when not found', async () => {
const { result } = setup([])
act(() => {
result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
await act(async () => {
await result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
expect(createTypeEntry).toHaveBeenCalledWith('NonExistent')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'template', '## Template')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { template: '## Template' })
})
})
describe('handleReorderSections', () => {
it('updates order on multiple type entries', () => {
it('updates order on multiple type entries', async () => {
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
const typeB = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
const { result } = setup([typeA, typeB])
act(() => {
result.current.handleReorderSections([
await act(async () => {
await result.current.handleReorderSections([
{ typeName: 'Note', order: 0 },
{ typeName: 'Project', order: 1 },
])
@@ -220,23 +230,24 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'order', 1)
expect(updateEntry).toHaveBeenCalledWith('/vault/type/note.md', { order: 0 })
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { order: 1 })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('skips types that are not found', () => {
it('auto-creates type entries when not found', async () => {
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
const { result } = setup([typeA])
act(() => {
result.current.handleReorderSections([
await act(async () => {
await result.current.handleReorderSections([
{ typeName: 'Note', order: 0 },
{ typeName: 'Missing', order: 1 },
])
})
// Only Note's order was set; Missing was skipped
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(1)
expect(createTypeEntry).toHaveBeenCalledWith('Missing')
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(2)
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/note.md', 'order', 0)
expect(updateEntry).toHaveBeenCalledTimes(1)
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/missing.md', 'order', 1)
})
})
@@ -251,6 +262,7 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('trims whitespace before saving', async () => {
@@ -278,15 +290,56 @@ describe('useEntryActions', () => {
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
})
it('does nothing when type entry not found', async () => {
it('auto-creates type entry when not found', async () => {
const { result } = setup([])
await act(async () => {
await result.current.handleRenameSection('NonExistent', 'Label')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
expect(createTypeEntry).toHaveBeenCalledWith('NonExistent')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'sidebar label', 'Label')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { sidebarLabel: 'Label' })
})
})
describe('handleToggleTypeVisibility', () => {
it('sets visible to false when currently visible (null/default)', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: null })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleToggleTypeVisibility('Journal')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false)
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('sets visible to true (deletes property) when currently hidden', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: false })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleToggleTypeVisibility('Journal')
})
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/journal.md', 'visible')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: null })
expect(onFrontmatterPersisted).toHaveBeenCalled()
})
it('auto-creates type entry when not found', async () => {
const { result } = setup([])
await act(async () => {
await result.current.handleToggleTypeVisibility('Journal')
})
expect(createTypeEntry).toHaveBeenCalledWith('Journal')
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false)
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
})
})
})

View File

@@ -8,6 +8,7 @@ interface EntryActionsConfig {
handleDeleteProperty: (path: string, key: string) => Promise<void>
setToastMessage: (msg: string | null) => void
createTypeEntry: (typeName: string) => Promise<VaultEntry>
onFrontmatterPersisted?: () => void
}
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
@@ -15,7 +16,7 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
}
export function useEntryActions({
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry,
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, onFrontmatterPersisted,
}: EntryActionsConfig) {
const handleTrashNote = useCallback(async (path: string) => {
const now = new Date().toISOString().slice(0, 10)
@@ -23,26 +24,30 @@ export function useEntryActions({
await handleUpdateFrontmatter(path, 'Trashed at', now)
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
setToastMessage('Note moved to trash')
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
onFrontmatterPersisted?.()
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
const handleRestoreNote = useCallback(async (path: string) => {
await handleUpdateFrontmatter(path, 'Trashed', false)
await handleDeleteProperty(path, 'Trashed at')
updateEntry(path, { trashed: false, trashedAt: null })
setToastMessage('Note restored from trash')
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage])
onFrontmatterPersisted?.()
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
const handleArchiveNote = useCallback(async (path: string) => {
await handleUpdateFrontmatter(path, 'archived', true)
updateEntry(path, { archived: true })
setToastMessage('Note archived')
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
onFrontmatterPersisted?.()
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
const handleUnarchiveNote = useCallback(async (path: string) => {
await handleUpdateFrontmatter(path, 'archived', false)
updateEntry(path, { archived: false })
setToastMessage('Note unarchived')
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
onFrontmatterPersisted?.()
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
let typeEntry = findTypeEntry(entries, typeName)
@@ -50,27 +55,30 @@ export function useEntryActions({
updateEntry(typeEntry.path, { icon, color })
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
const handleReorderSections = useCallback(async (orderedTypes: { typeName: string; order: number }[]) => {
for (const { typeName, order } of orderedTypes) {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) continue
handleUpdateFrontmatter(typeEntry.path, 'order', order)
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
await handleUpdateFrontmatter(typeEntry.path, 'order', order)
updateEntry(typeEntry.path, { order })
}
}, [entries, handleUpdateFrontmatter, updateEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleUpdateTypeTemplate = useCallback((typeName: string, template: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
handleUpdateFrontmatter(typeEntry.path, 'template', template)
const handleUpdateTypeTemplate = useCallback(async (typeName: string, template: string) => {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
await handleUpdateFrontmatter(typeEntry.path, 'template', template)
updateEntry(typeEntry.path, { template: template || null })
}, [entries, handleUpdateFrontmatter, updateEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
const trimmed = label.trim()
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
if (trimmed) {
@@ -78,7 +86,21 @@ export function useEntryActions({
} else {
await handleDeleteProperty(typeEntry.path, 'sidebar label')
}
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection }
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
if (typeEntry.visible === false) {
updateEntry(typeEntry.path, { visible: null })
await handleDeleteProperty(typeEntry.path, 'visible')
} else {
updateEntry(typeEntry.path, { visible: false })
await handleUpdateFrontmatter(typeEntry.path, 'visible', false)
}
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility }
}

View File

@@ -84,4 +84,62 @@ describe('useIndexing', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(typeof result.current.retryIndexing).toBe('function')
})
it('exposes triggerFullReindex function', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(typeof result.current.triggerFullReindex).toBe('function')
})
it('retryIndexing is the same reference as triggerFullReindex', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.retryIndexing).toBe(result.current.triggerFullReindex)
})
it('triggerFullReindex sets scanning phase then completes', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
await act(async () => { await result.current.triggerFullReindex() })
// In non-Tauri mode, it goes to 'complete' then auto-dismisses
expect(result.current.progress.phase).toBe('complete')
})
it('triggerFullReindex sets lastIndexedTime on success', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.lastIndexedTime).toBeNull()
await act(async () => { await result.current.triggerFullReindex() })
expect(result.current.lastIndexedTime).toBeGreaterThan(0)
})
it('triggerFullReindex sets error phase on failure', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
mockInvoke.mockRejectedValueOnce(new Error('indexing failed'))
await act(async () => { await result.current.triggerFullReindex() })
expect(result.current.progress.phase).toBe('error')
})
it('populates lastIndexedTime from backend metadata on mount', async () => {
mockInvoke.mockResolvedValue({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
last_indexed_commit: 'abc123',
last_indexed_at: 1709800000,
})
const { result } = renderHook(() => useIndexing('/test/vault'))
// Wait for the effect to run
await act(async () => { await vi.advanceTimersByTimeAsync(10) })
expect(result.current.lastIndexedTime).toBe(1709800000000) // seconds → ms
})
it('starts with lastIndexedTime as null', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.lastIndexedTime).toBeNull()
})
})

View File

@@ -17,6 +17,8 @@ interface IndexStatus {
indexed_count: number
embedded_count: number
pending_embed: number
last_indexed_commit: string | null
last_indexed_at: number | null
}
const IDLE: IndexingProgress = { phase: 'idle', current: 0, total: 0, done: false, error: null }
@@ -27,6 +29,7 @@ function invokeCmd<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
export function useIndexing(vaultPath: string) {
const [progress, setProgress] = useState<IndexingProgress>(IDLE)
const [lastIndexedTime, setLastIndexedTime] = useState<number | null>(null)
const indexingRef = useRef(false)
const vaultPathRef = useRef(vaultPath)
@@ -43,6 +46,9 @@ export function useIndexing(vaultPath: string) {
setProgress(event.payload)
if (event.payload.done) {
indexingRef.current = false
if (event.payload.phase === 'complete') {
setLastIndexedTime(Date.now())
}
}
})
cleanup = () => { unlisten.then(fn => fn()) }
@@ -61,6 +67,11 @@ export function useIndexing(vaultPath: string) {
const status = await invokeCmd<IndexStatus>('get_index_status', { vaultPath })
if (cancelled) return
// Populate last indexed time from backend metadata
if (status.last_indexed_at) {
setLastIndexedTime(status.last_indexed_at * 1000) // seconds → ms
}
// If qmd not installed or no collection or pending embeds, trigger indexing
const needsIndexing = !status.qmd_installed || !status.collection_exists || status.pending_embed > 0
if (needsIndexing && !indexingRef.current) {
@@ -116,17 +127,23 @@ export function useIndexing(vaultPath: string) {
if (indexingRef.current) return
try {
await invokeCmd('trigger_incremental_index', { vaultPath: vaultPathRef.current })
setLastIndexedTime(Date.now())
} catch {
// Incremental update failure is non-fatal
}
}, [])
const retryIndexing = useCallback(async () => {
const triggerFullReindex = useCallback(async () => {
if (indexingRef.current || !vaultPathRef.current) return
indexingRef.current = true
setProgress({ phase: 'scanning', current: 0, total: 0, done: false, error: null })
try {
await invokeCmd('start_indexing', { vaultPath: vaultPathRef.current })
// In non-Tauri mode, mark complete immediately
if (!isTauri()) {
setProgress({ phase: 'complete', current: 0, total: 0, done: true, error: null })
setLastIndexedTime(Date.now())
}
} catch (err) {
const msg = String(err)
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
@@ -136,5 +153,7 @@ export function useIndexing(vaultPath: string) {
}
}, [])
return { progress, triggerIncrementalIndex, retryIndexing }
const retryIndexing = triggerFullReindex
return { progress, lastIndexedTime, triggerIncrementalIndex, triggerFullReindex, retryIndexing }
}

View File

@@ -66,9 +66,10 @@ describe('useMcpStatus', () => {
})
it('install action calls register_mcp_tools and updates status', async () => {
// Auto-register fails (e.g. node not found), leaving status as not_installed
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('no node'))
return Promise.resolve(null)
})
@@ -76,12 +77,11 @@ describe('useMcpStatus', () => {
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
expect(result.current.mcpStatus).toBe('not_installed')
})
// Reset to test install action directly
// Now manual install succeeds
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
return Promise.resolve(null)
})
@@ -131,6 +131,33 @@ describe('useMcpStatus', () => {
})
})
it('install action shows restored toast when status was installed', async () => {
// First call: status already installed
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
if (cmd === 'register_mcp_tools') return Promise.resolve('updated')
return Promise.resolve(null)
})
const onToast = vi.fn()
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(result.current.mcpStatus).toBe('installed')
})
// Clear toasts from auto-register
onToast.mockClear()
// Now manually trigger install (restore)
await act(async () => {
await result.current.installMcp()
})
expect(result.current.mcpStatus).toBe('installed')
expect(onToast).toHaveBeenCalledWith('MCP server restored successfully')
})
it('does not show toast when already registered', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_mcp_status') return Promise.resolve('installed')

View File

@@ -19,9 +19,11 @@ export function useMcpStatus(
onToast: (msg: string) => void,
) {
const [status, setStatus] = useState<McpStatus>('checking')
const statusRef = useRef<McpStatus>(status)
const registeredRef = useRef<string | null>(null)
const onToastRef = useRef(onToast)
useEffect(() => { onToastRef.current = onToast })
useEffect(() => { statusRef.current = status }, [status])
// Check MCP status on vault open / vault switch
useEffect(() => {
@@ -57,11 +59,12 @@ export function useMcpStatus(
}, [vaultPath])
const install = useCallback(async () => {
const wasInstalled = statusRef.current === 'installed'
setStatus('checking')
try {
await tauriCall<string>('register_mcp_tools', { vaultPath })
setStatus('installed')
onToastRef.current('MCP server installed successfully')
onToastRef.current(wasInstalled ? 'MCP server restored successfully' : 'MCP server installed successfully')
} catch (e) {
setStatus('not_installed')
onToastRef.current(`MCP install failed: ${e}`)

View File

@@ -34,6 +34,7 @@ function makeHandlers(): MenuEventHandlers {
onResolveConflicts: vi.fn(),
onViewChanges: vi.fn(),
onInstallMcp: vi.fn(),
onReindexVault: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
activeTabPath: '/vault/test.md',
@@ -226,12 +227,6 @@ describe('dispatchMenuEvent', () => {
expect(h.onSelectFilter).toHaveBeenCalledWith('all')
})
it('go-favorites selects favorites filter', () => {
const h = makeHandlers()
dispatchMenuEvent('go-favorites', h)
expect(h.onSelectFilter).toHaveBeenCalledWith('favorites')
})
it('go-archived selects archived filter', () => {
const h = makeHandlers()
dispatchMenuEvent('go-archived', h)
@@ -305,6 +300,12 @@ describe('dispatchMenuEvent', () => {
expect(h.onInstallMcp).toHaveBeenCalled()
})
it('vault-reindex triggers reindex vault', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-reindex', h)
expect(h.onReindexVault).toHaveBeenCalled()
})
// Edge cases
it('unknown event ID does nothing', () => {
const h = makeHandlers()

View File

@@ -35,6 +35,8 @@ export interface MenuEventHandlers {
onResolveConflicts?: () => void
onViewChanges?: () => void
onInstallMcp?: () => void
onReindexVault?: () => void
onRepairVault?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
@@ -67,7 +69,6 @@ const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
const FILTER_MAP: Record<string, SidebarFilter> = {
'go-all-notes': 'all',
'go-favorites': 'favorites',
'go-archived': 'archived',
'go-trash': 'trash',
'go-changes': 'changes',
@@ -78,7 +79,7 @@ type OptionalHandler =
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
| 'onCreateTheme' | 'onRestoreDefaultThemes'
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp'
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onRepairVault'
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'view-go-back': 'onGoBack',
@@ -97,6 +98,8 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'vault-resolve-conflicts': 'onResolveConflicts',
'vault-view-changes': 'onViewChanges',
'vault-install-mcp': 'onInstallMcp',
'vault-reindex': 'onReindexVault',
'vault-repair': 'onRepairVault',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {

View File

@@ -28,6 +28,7 @@ vi.mock('../mock-tauri', () => ({
isTauri: vi.fn(() => false),
addMockEntry: vi.fn(),
updateMockContent: vi.fn(),
trackMockChange: vi.fn(),
mockInvoke: vi.fn().mockResolvedValue(''),
}))
vi.mock('./mockFrontmatterHelpers', () => ({
@@ -289,6 +290,8 @@ describe('frontmatterToEntryPatch', () => {
['trashed', true, { trashed: true }],
['order', 5, { order: 5 }],
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
['visible', false, { visible: false }],
['visible', true, { visible: null }],
] as [string, unknown, Partial<VaultEntry>][])(
'maps %s update to correct entry field',
(key, value, expected) => {
@@ -320,6 +323,7 @@ describe('frontmatterToEntryPatch', () => {
['archived', { archived: false }],
['order', { order: null }],
['template', { template: null }],
['visible', { visible: null }],
] as [string, Partial<VaultEntry>][])(
'maps delete of %s to null/default',
(key, expected) => {

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
import { isTauri, mockInvoke, addMockEntry, updateMockContent, trackMockChange } from '../mock-tauri'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import { useTabManagement } from './useTabManagement'
@@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
aliases: [], belongsTo: [], relatedTo: [],
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, properties: {},
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {},
}
}
@@ -111,12 +111,14 @@ async function invokeFrontmatter(command: string, args: Record<string, unknown>)
function applyMockFrontmatterUpdate(path: string, key: string, value: FrontmatterValue): string {
const content = updateMockFrontmatter(path, key, value)
updateMockContent(path, content)
trackMockChange(path)
return content
}
function applyMockFrontmatterDelete(path: string, key: string): string {
const content = deleteMockFrontmatterProperty(path, key)
updateMockContent(path, content)
trackMockChange(path)
return content
}
@@ -148,7 +150,7 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
template: { template: null }, sort: { sort: null },
template: { template: null }, sort: { sort: null }, visible: { visible: null },
}
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
@@ -168,6 +170,7 @@ export function frontmatterToEntryPatch(
template: { template: str },
sort: { sort: str },
view: { view: str },
visible: { visible: value === false ? false : null },
}
return updates[k] ?? {}
}

View File

@@ -1,53 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore'
function loadHiddenSections(): Set<string> {
const fromConfig = getVaultConfig().hidden_sections
if (fromConfig && fromConfig.length > 0) return new Set(fromConfig)
// Fallback to localStorage during initial load
try {
const raw = localStorage.getItem('laputa-hidden-sections')
if (raw) {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) return new Set(arr as string[])
}
} catch { /* ignore */ }
return new Set()
}
function saveHiddenSections(hidden: Set<string>): void {
const arr = [...hidden]
updateVaultConfigField('hidden_sections', arr.length > 0 ? arr : null)
}
export function useSectionVisibility() {
const [hiddenSections, setHiddenSections] = useState<Set<string>>(loadHiddenSections)
// Re-sync when vault config becomes available
useEffect(() => {
return subscribeVaultConfig(() => {
const sections = getVaultConfig().hidden_sections
if (sections) setHiddenSections(new Set(sections))
})
}, [])
const toggleSection = useCallback((type: string) => {
setHiddenSections((prev) => {
const next = new Set(prev)
if (next.has(type)) {
next.delete(type)
} else {
next.add(type)
}
saveHiddenSections(next)
return next
})
}, [])
const isSectionVisible = useCallback(
(type: string) => !hiddenSections.has(type),
[hiddenSections],
)
return { hiddenSections, toggleSection, isSectionVisible }
}

View File

@@ -34,7 +34,7 @@ function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
if (cmd === 'get_file_diff') return Promise.resolve('--- a/note.md\n+++ b/note.md')
if (cmd === 'get_file_diff_at_commit') return Promise.resolve(`diff for ${(args as Record<string, string>)?.commitHash}`)
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve('pushed')
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
return Promise.resolve(null)
}
@@ -382,6 +382,49 @@ describe('useVaultLoader', () => {
expect(response).toBe('Committed and pushed')
})
it('returns actionable message when push is rejected', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' })
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
let response = ''
await act(async () => {
response = await result.current.commitAndPush('test commit')
})
expect(response).toContain('Pull first')
expect(response).not.toBe('Committed and pushed')
})
it('returns network error message on network failure', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve({ status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' })
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
let response = ''
await act(async () => {
response = await result.current.commitAndPush('test commit')
})
expect(response).toContain('network error')
})
})
describe('loadModifiedFiles', () => {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState, startTransition } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus } from '../types'
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus, GitPushResult } from '../types'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
@@ -18,16 +18,12 @@ async function loadVaultData(vaultPath: string) {
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { message })
await mockInvoke<string>('git_push', {})
return 'Committed and pushed'
const pushResult = await mockInvoke<GitPushResult>('git_push', {})
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
}
await invoke<string>('git_commit', { vaultPath, message })
try {
await invoke<string>('git_push', { vaultPath })
return 'Committed and pushed'
} catch {
return 'Committed (push failed)'
}
const pushResult = await invoke<GitPushResult>('git_push', { vaultPath })
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
}
function useNewNoteTracker() {

View File

@@ -11,7 +11,7 @@ describe('useViewMode', () => {
beforeEach(() => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
})
@@ -26,7 +26,7 @@ describe('useViewMode', () => {
it('loads persisted view mode from vault config', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: 'editor-only', tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
{ zoom: null, view_mode: 'editor-only', tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
const { result } = renderHook(() => useViewMode())
@@ -69,7 +69,7 @@ describe('useViewMode', () => {
it('ignores invalid vault config values', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: 'garbage' as never, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
{ zoom: null, view_mode: 'garbage' as never, tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
const { result } = renderHook(() => useViewMode())

View File

@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'
import { useZoom } from './useZoom'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
const DEFAULT_VC = { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null } as const
const DEFAULT_VC = { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null } as const
describe('useZoom', () => {
beforeEach(() => {
@@ -109,4 +109,46 @@ describe('useZoom', () => {
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('dispatches laputa-zoom-change event on zoomIn', () => {
const handler = vi.fn()
window.addEventListener('laputa-zoom-change', handler)
const { result } = renderHook(() => useZoom())
handler.mockClear() // clear any init-phase dispatches
act(() => result.current.zoomIn())
expect(handler).toHaveBeenCalled()
window.removeEventListener('laputa-zoom-change', handler)
})
it('dispatches laputa-zoom-change event on zoomOut', () => {
const handler = vi.fn()
window.addEventListener('laputa-zoom-change', handler)
const { result } = renderHook(() => useZoom())
handler.mockClear()
act(() => result.current.zoomOut())
expect(handler).toHaveBeenCalled()
window.removeEventListener('laputa-zoom-change', handler)
})
it('dispatches laputa-zoom-change event on zoomReset', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
const handler = vi.fn()
window.addEventListener('laputa-zoom-change', handler)
const { result } = renderHook(() => useZoom())
handler.mockClear()
act(() => result.current.zoomReset())
expect(handler).toHaveBeenCalled()
window.removeEventListener('laputa-zoom-change', handler)
})
it('applies CSS zoom synchronously during initialization', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
const spy = vi.spyOn(document.documentElement.style, 'setProperty')
renderHook(() => useZoom())
// Zoom should be applied during state init (setProperty called with zoom value)
expect(spy).toHaveBeenCalledWith('zoom', '120%')
spy.mockRestore()
})
})

View File

@@ -29,6 +29,7 @@ function loadPersistedZoom(): number {
function applyZoomToDocument(level: number): void {
document.documentElement.style.setProperty('zoom', `${level}%`)
window.dispatchEvent(new Event('laputa-zoom-change'))
}
function persistZoom(level: number): void {
@@ -36,12 +37,13 @@ function persistZoom(level: number): void {
}
export function useZoom() {
const [zoomLevel, setZoomLevel] = useState(loadPersistedZoom)
// Apply persisted zoom on mount
useEffect(() => {
applyZoomToDocument(zoomLevel)
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- only on mount
const [zoomLevel, setZoomLevel] = useState(() => {
const level = loadPersistedZoom()
// Apply zoom synchronously during init so child components (e.g. CodeMirror)
// measure the correct scale factor in their own effects.
document.documentElement.style.setProperty('zoom', `${level}%`)
return level
})
// Re-sync when vault config becomes available
useEffect(() => {

View File

@@ -246,6 +246,22 @@
}
.ai-markdown a:hover { text-decoration: underline; }
.ai-markdown .chat-wikilink {
display: inline;
color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, transparent);
border-radius: 4px;
padding: 0 4px;
cursor: pointer;
font-weight: 500;
text-decoration: none;
transition: background 0.15s ease;
}
.ai-markdown .chat-wikilink:hover {
background: color-mix(in srgb, var(--primary) 20%, transparent);
text-decoration: underline;
}
.ai-markdown hr {
border: none;
border-top: 1px solid var(--border);

View File

@@ -5,18 +5,19 @@
*/
import { MOCK_CONTENT } from './mock-content'
import { mockHandlers, addMockEntry, updateMockContent } from './mock-handlers'
import { mockHandlers, addMockEntry, updateMockContent, trackMockChange } from './mock-handlers'
import { tryVaultApi } from './vault-api'
export { addMockEntry, updateMockContent }
export { addMockEntry, updateMockContent, trackMockChange }
export function isTauri(): boolean {
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
}
// Initialize window.__mockContent for browser testing
// Initialize window globals for browser testing and Playwright overrides
if (typeof window !== 'undefined') {
window.__mockContent = MOCK_CONTENT
window.__mockHandlers = mockHandlers
}
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {

View File

@@ -36,7 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
properties: { Priority: 'High', 'Due date': '2026-06-15' },
},
@@ -73,7 +73,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly' },
},
@@ -104,7 +104,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['person/matteo-cellini'],
properties: {},
},
@@ -135,7 +135,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -166,7 +166,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['responsibility/manage-sponsorships'],
properties: {},
},
@@ -198,7 +198,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
properties: { Priority: 'Low', 'Due date': '2026-03-01' },
},
@@ -230,7 +230,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
properties: { Priority: 'Medium', Rating: 4 },
},
@@ -261,7 +261,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['project/26q1-laputa-app'],
properties: {},
},
@@ -291,7 +291,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Company: 'Acme Corp', Role: 'Engineering Lead' },
},
@@ -321,7 +321,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Company: 'TechStart', Role: 'Product Manager' },
},
@@ -351,7 +351,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -381,7 +381,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -412,7 +412,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
properties: {},
},
@@ -443,7 +443,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -474,7 +474,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -505,7 +505,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -537,7 +537,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
properties: {},
},
@@ -568,7 +568,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -597,7 +597,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 0,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -625,7 +625,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 1,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -653,7 +653,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 2,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -681,7 +681,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 3,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: false,
outgoingLinks: [],
properties: {},
},
@@ -709,7 +709,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 4,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -737,7 +737,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 5,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -765,7 +765,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 6,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -793,7 +793,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 7,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -821,7 +821,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 8,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -850,7 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'orange',
order: 9,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -878,7 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'green',
order: 10,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -909,7 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 },
},
@@ -939,7 +939,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 },
},
@@ -971,7 +971,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -1001,7 +1001,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -1032,7 +1032,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -1055,7 +1055,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
modifiedAt: now - 86400 * 120,
@@ -1086,7 +1086,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
modifiedAt: now - 86400 * 90,
@@ -1151,7 +1151,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
order: null,
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
properties: {},
})
}
@@ -1184,7 +1184,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -1212,7 +1212,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
@@ -1240,7 +1240,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},

View File

@@ -3,7 +3,7 @@
* Each handler simulates a Tauri backend command.
*/
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
import { MOCK_CONTENT } from './mock-content'
import { MOCK_ENTRIES } from './mock-entries'
@@ -172,7 +172,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
get_build_number: () => 'bDEV',
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
git_push: () => 'Everything up-to-date',
git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }),
get_vault_pulse: (args: { limit?: number }): PulseCommit[] => {
const limit = args.limit ?? 30
const ts = Math.floor(Date.now() / 1000)
@@ -277,7 +277,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0 }),
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0, last_indexed_commit: 'abc123', last_indexed_at: Math.floor(Date.now() / 1000) - 3600 }),
start_indexing: () => null,
trigger_incremental_index: () => null,
list_themes: (): ThemeFile[] => [...mockThemes],
@@ -319,12 +319,23 @@ line-height-base: 1.6
# ${displayName}
`
const now = Date.now() / 1000
MOCK_ENTRIES.push({
path, filename: `${slug}.md`, title: displayName, isA: 'Theme',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 512, snippet: `A custom ${displayName} theme.`,
wordCount: 10, relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
})
syncWindowContent()
return path
},
ensure_vault_themes: (): null => null,
restore_default_themes: (): string => 'Default themes restored',
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }),
repair_vault: (): string => 'Vault repaired',
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null }),
save_vault_config: (): null => null,
}
@@ -337,3 +348,7 @@ export function updateMockContent(path: string, content: string): void {
MOCK_CONTENT[path] = content
syncWindowContent()
}
export function trackMockChange(path: string): void {
mockSavedSinceCommit.add(path)
}

View File

@@ -45,6 +45,13 @@ globalThis.ResizeObserver = class {
disconnect() {}
} as unknown as typeof ResizeObserver
// Mock IntersectionObserver for jsdom (not implemented)
globalThis.IntersectionObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof IntersectionObserver
// Mock @tauri-apps/plugin-opener for test environment
vi.mock('@tauri-apps/plugin-opener', () => ({
openUrl: vi.fn(),

View File

@@ -33,6 +33,8 @@ export interface VaultEntry {
sort: string | null
/** Default view mode for the note list of this Type: "all", "editor-list", or "editor-only". */
view: string | null
/** Whether this Type is visible in the sidebar. Defaults to true when absent. */
visible: boolean | null
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
outgoingLinks: string[]
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
@@ -76,6 +78,11 @@ export interface GitPullResult {
conflictFiles: string[]
}
export interface GitPushResult {
status: 'ok' | 'rejected' | 'auth_error' | 'network_error' | 'error'
message: string
}
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict'
export interface DeviceFlowStart {
@@ -148,7 +155,6 @@ export interface VaultConfig {
tag_colors: Record<string, string> | null
status_colors: Record<string, string> | null
property_display_modes: Record<string, string> | null
hidden_sections: string[] | null
}
export interface PulseFile {
@@ -169,7 +175,7 @@ export interface PulseCommit {
deleted: number
}
export type SidebarFilter = 'all' | 'favorites' | 'archived' | 'trash' | 'changes' | 'pulse'
export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
export type SidebarSelection =
| { kind: 'filter'; filter: SidebarFilter }

View File

@@ -23,6 +23,12 @@ describe('buildAgentSystemPrompt', () => {
expect(prompt).toContain('Vault context:')
expect(prompt).toContain('Recent notes: foo, bar')
})
it('instructs AI to use wikilink syntax', () => {
const prompt = buildAgentSystemPrompt()
expect(prompt).toContain('[[')
expect(prompt).toMatch(/wikilink/i)
})
})
// --- streamClaudeAgent ---
@@ -44,7 +50,7 @@ describe('streamClaudeAgent', () => {
// Wait for the setTimeout mock response
await new Promise(r => setTimeout(r, 400))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Build Laputa App'))
expect(onDone).toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
expect(onToolStart).not.toHaveBeenCalled()

View File

@@ -17,6 +17,7 @@ You have full shell access. Use bash for file operations, search, bulk edits.
Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note).
When you create or edit a note, call open_note(path) so the user sees it in Laputa.
When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.
Be concise and helpful. When you've completed a task, briefly summarize what you did.`
export function buildAgentSystemPrompt(vaultContext?: string): string {
@@ -57,7 +58,7 @@ export async function streamClaudeAgent(
): Promise<void> {
if (!isTauri()) {
setTimeout(() => {
callbacks.onText('AI Agent requires the Claude CLI. Install it and run the native app.')
callbacks.onText('This note is related to [[Build Laputa App]] and [[Matteo Cellini]]. The AI Agent requires the Claude CLI for full functionality.')
callbacks.onDone()
}, 300)
return

View File

@@ -8,6 +8,8 @@ vi.mock('../mock-tauri', () => ({
import {
estimateTokens, buildSystemPrompt,
nextMessageId, checkClaudeCli, streamClaudeChat,
trimHistory, formatMessageWithHistory,
type ChatMessage, MAX_HISTORY_TOKENS,
} from './ai-chat'
import type { VaultEntry } from '../types'
@@ -50,6 +52,14 @@ describe('buildSystemPrompt', () => {
expect(result.prompt).toContain('Hello world')
expect(result.totalTokens).toBeGreaterThan(0)
})
it('instructs AI to use wikilink syntax', () => {
const notes = [makeEntry('/test.md', 'Test Note')]
const content = { '/test.md': 'content' }
const result = buildSystemPrompt(notes, content)
expect(result.prompt).toContain('[[')
expect(result.prompt).toMatch(/wikilink/i)
})
})
// --- nextMessageId ---
@@ -73,6 +83,107 @@ describe('checkClaudeCli', () => {
})
})
// --- trimHistory ---
describe('trimHistory', () => {
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
role, content, id: `msg-${content}`,
})
it('returns empty array for empty history', () => {
expect(trimHistory([], 1000)).toEqual([])
})
it('returns all messages when under token limit', () => {
const history = [msg('user', 'hi'), msg('assistant', 'hello')]
expect(trimHistory(history, 1000)).toEqual(history)
})
it('drops oldest messages when over token limit', () => {
const history = [
msg('user', 'a'.repeat(400)), // 100 tokens
msg('assistant', 'b'.repeat(400)), // 100 tokens
msg('user', 'c'.repeat(400)), // 100 tokens
]
const result = trimHistory(history, 200)
// Should keep the two most recent messages (200 tokens)
expect(result).toHaveLength(2)
expect(result[0].content).toBe('b'.repeat(400))
expect(result[1].content).toBe('c'.repeat(400))
})
it('keeps at least one message if it fits', () => {
const history = [
msg('user', 'a'.repeat(2000)), // 500 tokens
msg('assistant', 'b'.repeat(80)), // 20 tokens
]
const result = trimHistory(history, 30)
expect(result).toHaveLength(1)
expect(result[0].content).toBe('b'.repeat(80))
})
it('returns empty when single message exceeds limit', () => {
const history = [msg('user', 'a'.repeat(4000))] // 1000 tokens
expect(trimHistory(history, 10)).toEqual([])
})
})
// --- formatMessageWithHistory ---
describe('formatMessageWithHistory', () => {
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
role, content, id: `msg-${content}`,
})
it('returns bare message when no history', () => {
expect(formatMessageWithHistory([], 'hello')).toBe('hello')
})
it('includes conversation history before the new message', () => {
const history = [msg('user', 'What is Rust?'), msg('assistant', 'A systems language.')]
const result = formatMessageWithHistory(history, 'How does it compare to Go?')
expect(result).toContain('What is Rust?')
expect(result).toContain('A systems language.')
expect(result).toContain('How does it compare to Go?')
})
it('labels user and assistant messages correctly', () => {
const history = [msg('user', 'Q1'), msg('assistant', 'A1')]
const result = formatMessageWithHistory(history, 'Q2')
expect(result).toContain('[user]: Q1')
expect(result).toContain('[assistant]: A1')
expect(result).toContain('[user]: Q2')
})
it('preserves message order', () => {
const history = [
msg('user', 'first'),
msg('assistant', 'second'),
msg('user', 'third'),
msg('assistant', 'fourth'),
]
const result = formatMessageWithHistory(history, 'fifth')
const firstIdx = result.indexOf('first')
const secondIdx = result.indexOf('second')
const thirdIdx = result.indexOf('third')
const fourthIdx = result.indexOf('fourth')
const fifthIdx = result.indexOf('fifth')
expect(firstIdx).toBeLessThan(secondIdx)
expect(secondIdx).toBeLessThan(thirdIdx)
expect(thirdIdx).toBeLessThan(fourthIdx)
expect(fourthIdx).toBeLessThan(fifthIdx)
})
})
// --- MAX_HISTORY_TOKENS ---
describe('MAX_HISTORY_TOKENS', () => {
it('is a reasonable token limit', () => {
expect(MAX_HISTORY_TOKENS).toBeGreaterThan(10_000)
expect(MAX_HISTORY_TOKENS).toBeLessThan(200_000)
})
})
// --- streamClaudeChat ---
describe('streamClaudeChat', () => {

View File

@@ -34,6 +34,7 @@ export function buildSystemPrompt(
const preamble = [
'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
'The user has selected the following notes as context. Use them to answer questions accurately.',
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
'',
].join('\n')
@@ -76,6 +77,34 @@ export function nextMessageId(): string {
return `msg-${++msgIdCounter}-${Date.now()}`
}
// --- Conversation history ---
/** Max tokens of history to include in each request. */
export const MAX_HISTORY_TOKENS = 100_000
/** Keep the most recent messages that fit within `maxTokens`. Drops oldest first. */
export function trimHistory(history: ChatMessage[], maxTokens: number): ChatMessage[] {
let tokenCount = 0
const result: ChatMessage[] = []
for (let i = history.length - 1; i >= 0; i--) {
const tokens = estimateTokens(history[i].content)
if (tokenCount + tokens > maxTokens) break
result.unshift(history[i])
tokenCount += tokens
}
return result
}
/** Format conversation history + new message into a single prompt for the CLI. */
export function formatMessageWithHistory(history: ChatMessage[], newMessage: string): string {
if (history.length === 0) return newMessage
const lines = history.map(m => `[${m.role}]: ${m.content}`)
lines.push(`[user]: ${newMessage}`)
return `<conversation_history>\n${lines.join('\n\n')}\n</conversation_history>\n\nContinue the conversation. Respond only to the latest [user] message.`
}
// --- Claude CLI status ---
export interface ClaudeCliStatus {

View File

@@ -331,6 +331,41 @@ describe('buildContextSnapshot', () => {
expect(json.noteList).toBeUndefined()
})
it('uses activeNoteContent for body when allContent is empty (Tauri mode)', () => {
const emptyAllContent: Record<string, string> = {}
const result = buildContextSnapshot({
activeEntry: active,
allContent: emptyAllContent,
entries,
activeNoteContent: '---\ntitle: Alpha\n---\n\n# Alpha\nProject content from tab.',
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body).toContain('Project content from tab.')
})
it('prefers activeNoteContent over allContent when both present', () => {
const result = buildContextSnapshot({
activeEntry: active,
allContent,
entries,
activeNoteContent: 'Fresh editor content',
})
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body).toBe('Fresh editor content')
})
it('falls back to allContent when activeNoteContent is undefined', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
const json = JSON.parse(result.split('```json\n')[1].split('\n```')[0])
expect(json.activeNote.body).toBe('# Alpha\nProject content.')
})
it('includes wikilink instruction in preamble', () => {
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
expect(result).toContain('[[Note Title]]')
expect(result).toContain('wikilink')
})
it('includes belongsTo and relatedTo in frontmatter', () => {
const entryWithRels = makeEntry({
path: '/vault/a.md', title: 'Alpha',

View File

@@ -72,6 +72,8 @@ export interface NoteListItem {
export interface ContextSnapshotParams {
activeEntry: VaultEntry
allContent: Record<string, string>
/** Direct content of the active note from the editor tab (most reliable source). */
activeNoteContent?: string
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
@@ -94,7 +96,7 @@ const MAX_NOTE_LIST_ITEMS = 100
/** Build a structured context snapshot as a system prompt for Claude. */
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const { activeEntry, allContent, openTabs, noteList, noteListFilter, entries, references } = params
const { activeEntry, allContent, activeNoteContent, openTabs, noteList, noteListFilter, entries, references } = params
const snapshot: Record<string, unknown> = {
activeNote: {
@@ -102,7 +104,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
title: activeEntry.title,
type: activeEntry.isA ?? 'Note',
frontmatter: entryFrontmatter(activeEntry),
body: allContent[activeEntry.path] ?? '',
body: activeNoteContent ?? allContent[activeEntry.path] ?? '',
},
}
@@ -152,6 +154,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
].join('\n')
return `${preamble}\n\n## Context Snapshot\n\`\`\`json\n${JSON.stringify(snapshot, null, 2)}\n\`\`\``

View File

@@ -0,0 +1,35 @@
const WIKILINK_SCHEME = 'wikilink://'
export { WIKILINK_SCHEME }
function encodeTarget(target: string): string {
return encodeURIComponent(target).replace(/\(/g, '%28').replace(/\)/g, '%29')
}
function escapeLinkText(text: string): string {
return text.replace(/[[\]]/g, '\\$&')
}
function replaceWikilinksInText(text: string): string {
return text.replace(/\[\[([^\]]+)\]\]/g, (_, inner: string) => {
const pipeIdx = inner.indexOf('|')
const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
const display = pipeIdx !== -1 ? inner.slice(pipeIdx + 1) : inner
return `[${escapeLinkText(display)}](${WIKILINK_SCHEME}${encodeTarget(target)})`
})
}
/** Convert [[Target]] to markdown links, but skip code blocks and inline code. */
export function preprocessWikilinks(md: string): string {
const segments: string[] = []
const codeRegex = /(```[\s\S]*?```|`[^`\n]+`)/g
let lastIndex = 0
let match
while ((match = codeRegex.exec(md)) !== null) {
segments.push(replaceWikilinksInText(md.slice(lastIndex, match.index)))
segments.push(match[0])
lastIndex = match.index + match[0].length
}
segments.push(replaceWikilinksInText(md.slice(lastIndex)))
return segments.join('')
}

View File

@@ -9,7 +9,6 @@ function makeConfig(overrides: Partial<VaultConfig> = {}): VaultConfig {
tag_colors: null,
status_colors: null,
property_display_modes: null,
hidden_sections: null,
...overrides,
}
}
@@ -116,27 +115,13 @@ describe('migrateLocalStorageToVaultConfig', () => {
expect(result.property_display_modes).toBeNull()
})
// 8. Hidden sections migration
it('migrates populated hidden sections array', () => {
store['laputa-hidden-sections'] = JSON.stringify(['backlinks', 'properties'])
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.hidden_sections).toEqual(['backlinks', 'properties'])
})
it('ignores empty hidden sections array', () => {
store['laputa-hidden-sections'] = JSON.stringify([])
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.hidden_sections).toBeNull()
})
// 9. Existing config values are NOT overwritten
// 8. Existing config values are NOT overwritten
it('does not overwrite existing config values with localStorage data', () => {
store['laputa:zoom-level'] = '120'
store['laputa-view-mode'] = 'all'
store['laputa:tag-color-overrides'] = JSON.stringify({ x: '#fff' })
store['laputa:status-color-overrides'] = JSON.stringify({ y: '#000' })
store['laputa:display-mode-overrides'] = JSON.stringify({ z: 'compact' })
store['laputa-hidden-sections'] = JSON.stringify(['nav'])
const existing = makeConfig({
zoom: 0.9,
@@ -144,7 +129,6 @@ describe('migrateLocalStorageToVaultConfig', () => {
tag_colors: { existing: '#aaa' },
status_colors: { existing: '#bbb' },
property_display_modes: { existing: 'full' },
hidden_sections: ['sidebar'],
})
const result = migrateLocalStorageToVaultConfig(existing)
@@ -153,7 +137,6 @@ describe('migrateLocalStorageToVaultConfig', () => {
expect(result.tag_colors).toEqual({ existing: '#aaa' })
expect(result.status_colors).toEqual({ existing: '#bbb' })
expect(result.property_display_modes).toEqual({ existing: 'full' })
expect(result.hidden_sections).toEqual(['sidebar'])
})
// 10. loaded=null (no vault config file yet) — uses defaults then migrates

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