Compare commits

...

65 Commits

Author SHA1 Message Date
Test
abf6b51369 fix: scope Playwright selectors to dialog overlays to avoid sidebar matches
The `span.truncate` selector was matching sidebar note titles in addition
to search results, causing false positives in the full-text search test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:59:57 +01:00
Test
02c784b286 style: apply cargo fmt to is_file_trashed tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:44:37 +01:00
Test
fc4ba24c4e fix: exclude trashed notes from search results and autocomplete
Trashed notes were appearing in search (Cmd+F), Quick Open (Ctrl+P),
wikilink autocomplete ([[), and person mention autocomplete (@).

Rust: add is_file_trashed() to check frontmatter, filter search_vault results.
Frontend: filter trashed entries from useNoteSearch, baseItems in both
editor views, and mock search_vault handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:42:56 +01:00
Test
eaeb6e5d40 fix: simplify cache-invalidation smoke test — container visibility only 2026-03-09 12:20:24 +01:00
Test
64fe0f1c25 chore: rotate Tauri signing keypair — fix CI release builds 2026-03-09 12:20:24 +01:00
Test
474353718a fix: handle Archived/Trashed Yes/No string values in frontmatter parser
The Rust YAML parser only accepted boolean values (true/false) for the
archived and trashed fields. When the vault writes Archived: Yes or
Trashed: Yes (YAML string, not boolean), serde silently returned None
and the note appeared as non-archived/non-trashed.

Add a custom deserializer that accepts both booleans and string
representations (Yes/yes/YES/true/1 → true, No/no/false/0 → false).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:59:27 +01:00
Test
a933e6a787 fix: add reload_vault to vault API proxy for browser mock
The vault-api proxy maps Tauri commands to HTTP endpoints when a vault
API server is running. Without this, reload_vault bypassed the proxy
and Playwright route interceptors couldn't catch vault reload calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:14:34 +01:00
Test
79689839b2 style: apply cargo fmt to new tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:14:34 +01:00
Test
b10e9facad fix: reload vault invalidates cache and rescans from filesystem
Reload Vault (Cmd+K) now calls the new `reload_vault` Tauri command which
deletes the cache file before scanning, guaranteeing a full filesystem
rescan. Previously it called `list_vault` which used incremental git-based
cache updates that could miss recent changes (e.g. trashing a note then
reloading showed stale data).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:14:34 +01:00
Test
2a32d2b5ad fix: resolve TypeScript overload errors in zoomCursorFix
Use untyped function references to avoid conflicts with
posAtCoords overloaded type signatures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:54:42 +01:00
Test
11a4e1593b test: add Playwright smoke test for CodeMirror cursor at non-100% zoom
Covers clicking at 150%, 80%, and double-click word selection at 125%.
Verifies cursor lands near the click point (within first 30 chars of line)
and that word selection produces a non-empty range.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:51:57 +01:00
Test
080e5ff62d fix: bypass CodeMirror posAtCoords for accurate cursor at non-100% CSS zoom
CSS zoom on document.documentElement causes a coordinate space mismatch
between mouse event clientX/Y (viewport space) and Range.getClientRects()
(CSS space), breaking CodeMirror's click-to-position mapping. The previous
requestMeasure() fix only recalibrated cached geometry, not this mismatch.

New approach: zoomCursorFix extension patches posAtCoords/posAndSideAtCoords
on the EditorView instance to use document.caretRangeFromPoint() — the
browser's native, zoom-aware API — with coord-adjustment fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:43:46 +01:00
Test
60fd4d9ade fix: embed conversation history in AI agent chat messages
The AI chat panel (AiPanel → useAiAgent) was sending each message as a
standalone request with no prior context. Root cause: useAiAgent.sendMessage
called streamClaudeAgent with raw text, never embedding history.

- Add agentMessagesToChatHistory() to convert AiAgentMessage[] to ChatMessage[]
- Embed trimmed history in each agent request via formatMessageWithHistory
- Use messagesRef/statusRef to avoid stale closures in async callbacks
- Also fix useAIChat (dead code path) with same ref pattern
- Update mock layers to detect history presence for testability
- Add Playwright smoke tests verifying history accumulates and resets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:21:49 +01:00
Test
3583cb9518 ci: retrigger release — fix Tauri signing key secret 2026-03-09 10:00:59 +01:00
Test
1f497e4b18 test: fix flaky command palette smoke test — use reindex instead of settings
Settings command is disabled in mock environment (onOpenSettings not wired),
causing the 'typing filters the command list' test to always fail.
Reindex Vault is always enabled and already tested in indexing-reindex-status.spec.ts.
2026-03-09 09:28:48 +01:00
Test
13b325217b docs: consolidate VISION.md into docs/ — remove duplicate root file 2026-03-09 09:25:48 +01:00
Test
1714da402e fix: prune stale cache entries on vault open, not just cache write
Remove the early return in update_same_commit that skipped filesystem
validation when git reported no changes. Add prune_stale_entries to
finalize_and_cache so every vault scan path validates entries exist on
disk and deduplicates by case-folded path. Prevents ghost notes after
deleting files outside the app (e.g., via Finder).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 03:06:55 +01:00
Test
7b75cb79c4 fix: add 1 retry for Playwright smoke tests to handle server startup timing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:58:24 +01:00
Test
a66eedbecd style: rustfmt vault/mod.rs test formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:52:56 +01:00
Test
dc92fd1f57 fix: disk-first writes in useEntryActions, document three-layer model
Move updateEntry() calls after handleUpdateFrontmatter/handleDeleteProperty
in handleCustomizeType, handleRenameSection, and handleToggleTypeVisibility
so React state only updates after the disk write succeeds. This prevents
state-disk divergence when writes fail.

Expand ARCHITECTURE.md "Three representations, one authority" section with
ownership rules, invariants table, and recovery mechanisms. Add
reload_vault_entry to the commands table (62 total).

Add Playwright smoke test for Reload Vault in Cmd+K palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:52:56 +01:00
Test
4012a65a73 feat: wire Reload Vault into Cmd+K palette and menu bar
Adds a "Reload Vault" command that forces a full rescan from filesystem,
bypassing cache. Available via Cmd+K and Vault menu. Wired through
useAppCommands → useCommandRegistry and useMenuEvents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:52:56 +01:00
Test
b2da813923 feat: add reload_vault_entry Tauri command
Re-reads a single .md file from disk and returns a fresh VaultEntry.
Used after failed optimistic updates to restore the true filesystem state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:52:56 +01:00
Test
e461a91721 refactor: remove hardcoded RELATIONSHIP_KEYS — detect wikilink fields dynamically
Any frontmatter field whose value contains [[wikilinks]] now renders as a
relationship chip automatically. Fields with plain-text values always render
as editable properties, even if they were formerly hardcoded relationship keys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:33:57 +01:00
Test
edf24898ae test: add Playwright smoke test for exact-match search ranking
Verifies that searching "Writing" in Quick Open shows the exact title
match first, followed by prefix matches. Also adds Refactoring test
entries to mock data and demo vault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:53:00 +01:00
Test
1c8542bd01 feat: flip canonical type field — make type: primary, Is A: the alias
The Rust parser now treats `type:` as the canonical frontmatter field
for entity type, with `Is A:` and `is_a:` accepted as legacy aliases.
Previously it was the other way around, creating an asymmetric
read/write cycle since the frontend and all 8800+ vault notes already
use `type:`.

- Flip serde attribute: rename="type", alias="Is A", alias="is_a"
- Update theme defaults, getting-started vault, and type definitions
- Add round-trip tests for both type: and Is A: parsing
- Update mock data and TypeScript tests to use canonical form

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:38:17 +01:00
Test
aef98f17eb feat: move vault cache to ~/.laputa/cache/ and make writes atomic
Cache files are now stored outside the vault directory at
~/.laputa/cache/<vault-hash>.json, preventing them from polluting
the user's git repo. Writes use atomic tmp+rename to avoid corruption.
Legacy .laputa-cache.json files are auto-migrated and cleaned up on
first run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:04:14 +01:00
Test
5c85bc41f6 test: add Playwright smoke test for exact-match search ranking
Verifies that searching "Writing" in Quick Open shows the exact title
match first, followed by prefix matches. Also adds Refactoring test
entries to mock data and demo vault.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:15:08 +01:00
Test
60f3139b3e fix: ensure exact title match always ranks first in search
Title exact match gets exclusive tier 0 — alias exact match is capped
at tier 1, so a note titled "Refactoring" always appears above notes
with "Refactoring" as an alias or prefix. The 5-tier ranking is:
0=title exact, 1=alias exact, 2=title prefix, 3=alias prefix, 4=fuzzy.

Also adds ranking to editor wikilink autocomplete (enrichSuggestionItems)
and trims whitespace in searchRank comparisons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 22:03:54 +01:00
Test
e40c09a2ef style: apply rustfmt to rename.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:52:30 +01:00
Test
f90f703096 test: add Playwright smoke test for move-note-to-type-folder
Covers type change → move toast confirmation and type selector visibility
in the properties panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:52:30 +01:00
Test
eaf31ff61c feat: move note to type folder when Is A changes
When the user changes a note's type via the Properties panel,
the note file is automatically moved to the corresponding type folder.
Shows a toast confirming the move. No move if already in correct folder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:52:30 +01:00
Test
db50f779c9 feat: add move_note_to_type_folder backend command
Adds a new Tauri command that moves a note file to the folder
corresponding to its new type when Is A is changed. Handles:
- folder creation, filename collision (-2 suffix), wikilink updates,
  and no-op when already in the correct folder.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:52:30 +01:00
Test
e228bd3a52 docs: add Refactoring strategic context to VISION.md
Laputa as proof-of-work for Refactoring's credibility:
- Building publicly validates the author's authority to write about
  building software with AI (not theory — demonstrated practice)
- Open source makes the work visible: GitHub commits are public evidence
- Success converts to reputation/acquisition for Refactoring via
  sponsorships, paid subs, and brand authority
- Strategy: build the tool you describe, make the work visible
2026-03-08 21:52:30 +01:00
Test
3a0fd0620f docs: add Phase 1b — Tauri dev QA for filesystem/native tasks
Claude Code must also test with pnpm tauri dev (not just Playwright)
when the task touches: filesystem, AI context pipeline, MCP server,
git integration, or native Tauri commands.

Playwright tests mock-tauri handlers — they cannot catch bugs in the
real file read/write layer. Phase 1b closes this gap.

Lesson from ai-chat-empty-body: bug was in MCP server reading from disk,
invisible to Playwright. Phase 1b would have caught it in attempt 1.
2026-03-08 21:52:30 +01:00
Test
e2489b8957 feat: exact-match-first ranking in search and wikilink autocomplete
Add searchRank/bestSearchRank utilities that compute a tier (0=exact,
1=prefix, 2=fuzzy-only). Both useNoteSearch and WikilinkChatInput now
sort by rank tier first, then by fuzzy score, ensuring notes with exact
title or alias matches always surface above partial/fuzzy matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:00:58 +01:00
Test
11f8731d32 docs: strengthen Phase 1 QA requirements in CLAUDE.md
- Require Claude Code to write a new task-specific Playwright test for
  every task (not just run existing smoke tests)
- Test must fail before fix and pass after — proves coverage
- Clarify that Phase 1 is Claude Code's quality gate, not Brian's
- Brian's Phase 2 is a reinforcement check; if he finds a bug that
  Phase 1 should have caught, that is a Phase 1 failure

Lesson from ai-chat-empty-body: 5 QA cycles happened because Phase 1
never verified that the AI actually received note content end-to-end.
2026-03-08 20:54:44 +01:00
Test
6f7a7d71d8 docs: add 'why this, why now, why us' section to VISION.md
Strongest possible answer to 'why are you the right person to build this':
- Generalist CTO who can build end-to-end
- 300+ articles = battle-tested PKM system at scale
- Refactoring distribution (~200K subscribers) = built-in audience
- Not theorized — the method is proven by the output that exists
2026-03-08 20:48:58 +01:00
Test
aef1924407 Merge branch 'main' of https://github.com/refactoringhq/laputa-app 2026-03-08 20:47:19 +01:00
Test
2173df6f0d docs: clarify that evergreen notes are one output type, not the only one
The capture→organize→express framework is output-agnostic:
- Writers: evergreen notes as building blocks for articles
- Builders: project knowledge graph and shipped work
- Operators: procedures and responsibility systems
What varies is the expression layer; the discipline is universal.
2026-03-08 20:37:21 +01:00
Test
98cad76aa0 feat: fast note open — use allContent cache to skip IPC disk reads
handleSelectNote and handleReplaceActiveTab now check the in-memory
allContent cache before issuing a Tauri IPC call. Cache hits open the
tab synchronously (zero latency). Cache misses fall back to the disk
read and populate allContent via onContentLoaded so subsequent opens
of the same note are instant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:33:57 +01:00
Test
f076c71cc1 docs: add purpose-driven notes and evergreen notes to VISION.md
Two additions from Luca's published essays on note-taking:
- 'Knowledge has a purpose' section: notes exist to get things done,
  not for abstract future use. Without purpose, the system collapses.
- Evergreen notes concept: atomic, timeless, reusable units of thought.
  The most valuable layer of a mature vault.
- Organize phase clarified: weekly cadence, deleting >50% of captures
  is normal and healthy, not a failure.
2026-03-08 20:25:56 +01:00
Test
44221e50d4 docs: rewrite VISION.md as a coherent product narrative
Complete rewrite structured around three pillars:
1. The problem (architectural + methodological)
2. The method (ontology, capture/organize, convention over configuration)
3. The foundation (local files, Git, AI-native architecture)

Key improvement: the document now explains *why* tool and method together
is the differentiating insight — not just a list of features and principles.
Includes the three-stage product trajectory and updated design principles.
Current state section condensed; full roadmap moved to ROADMAP.md.
2026-03-08 20:23:33 +01:00
Test
b46c71c76f Merge branch 'main' of https://github.com/refactoringhq/laputa-app 2026-03-08 20:19:15 +01:00
Test
06c01539af docs: add capture/organize philosophy and Inbox to vision and roadmap
VISION.md:
- New section 'The two-phase knowledge workflow: capture and organize'
- Explains capture (fast, frictionless, everywhere) vs organize (deliberate,
  periodic) as fundamentally different activities
- Defines Inbox: a smart filter showing notes with no outgoing relationships
- Inbox Zero as the goal; connecting a note removes it automatically
- Replaces 'All Notes' as the primary navigation section

ROADMAP.md:
- New strategic direction #4: Inbox and capture pipeline
- Covers inbox UI, capture integrations (Chrome ext, iPhone, Readwise, voice)
2026-03-08 20:18:11 +01:00
Test
fddc323d1e docs: add ROADMAP.md with strategic directions
Four strategic directions documented:
1. Semantic properties (conventional fields with rich UI rendering)
2. Default relationships in Properties panel (opinionated defaults)
3. Global workspace filter (with multi-vault/team future trajectory)
4. Mobile apps (iPhone for capture, iPad as desktop mirror)

Plus consolidation sprint summary and roadmap principles.
2026-03-08 20:10:27 +01:00
Test
23b63bb583 docs: expand VISION.md with product trajectory and updated principles
- Add 'Product trajectory' section: 3 stages from personal PKM → indie
  knowledge workers → small teams, with rationale for why the same
  foundational model (local files + Git) enables all three stages
- Note that the knowledge ontology (Projects/Responsibilities/Procedures/
  Notes/People/Events) maps equally well to personal and organizational use
- Describe workspace feature as the seed for future team access control
- Update design principles: add convention over configuration, semantic
  properties, filesystem as source of truth; expand AI-native principle
  to include AI-readability via shared conventions
2026-03-08 20:06:40 +01:00
Test
c858cf8d3b fix: use vault path for resolveNewNote, resolveNewType, resolveDailyNote
Remove hardcoded /Users/luca/Laputa/ paths from resolveNewNote,
resolveNewType, and resolveDailyNote. All three now accept a vaultPath
parameter and build paths relative to the active vault. Added vaultPath
to NoteActionsConfig so the hook passes it through to all callers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:00:20 +01:00
Test
0dc684453d docs: add design principles and semantic field conventions
- ARCHITECTURE.md: new Design Principles section covering filesystem as
  source of truth, convention over configuration, no hardcoded exceptions,
  AI-first knowledge graph, and three-representation model
- ABSTRACTIONS.md: design philosophy intro + semantic field names table
  documenting all conventional frontmatter fields and their UI behavior

Convention over configuration principle explicitly noted as serving
AI-readability: shared conventions make vaults navigable by AI agents
without bespoke per-vault instructions.
2026-03-08 19:58:28 +01:00
Test
aafe69b573 fix: show all scalar properties in Properties panel — remove Owner from RELATIONSHIP_KEYS, remove notion_id from SKIP_KEYS 2026-03-08 19:46:18 +01:00
Test
a3c53c19d1 fix: resolve AI chat empty body race — read contextPrompt from closure, not stale ref
contextRef (useRef) was initialized at mount time and synced via useEffect,
which runs after paint. If contextPrompt was empty at mount (tab content
not yet loaded), sendMessage could read stale/empty context during the
window between paint and effect. Removing the ref and reading contextPrompt
directly in the useCallback closure eliminates the race entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:02:21 +01:00
Test
5185f363e6 feat: show type instances in inspector Properties panel
When viewing a Type note (e.g. "Project"), the Properties panel now shows
an Instances section listing all notes of that type, sorted by modified_at
descending. Trashed instances are excluded, archived instances are dimmed.
Display capped at 50 with count badge for large collections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:14:14 +01:00
Test
3915363026 docs: update 'Is A' → 'type:' throughout — type: is now canonical
The frontmatter field for entity type is now 'type:' (not 'Is A:').
The Rust parser accepts both via serde alias, but all documentation,
examples, and new code should use 'type:'.

Updated: ABSTRACTIONS.md, ARCHITECTURE.md, PROJECT-SPEC.md, GETTING-STARTED.md
2026-03-08 18:03:02 +01:00
Test
2249c4a450 fix: resolve AI chat empty body via || fallback + defensive body + Rust strip_frontmatter
Three root causes identified and fixed:

1. JS semantics bug: `activeNoteContent ?? allContent[path]` used nullish
   coalescing (`??`) which does NOT fall through on empty string ''. When
   handleEditorChange temporarily overwrites tab.content with frontmatter-
   only content during async content swaps, activeNoteContent becomes ''
   and the fallback to allContent never triggers. Fix: change `??` to `||`.

2. Defence-in-depth: when body is still empty after fallback (Tauri mode
   where allContent is {}), but wordCount > 0, the body field now includes
   an explicit get_note instruction instead of being empty. This is more
   reliable than the preamble instruction that Claude may skip.

3. Rust strip_frontmatter used `rest.find("---")` which matches `---`
   anywhere in text (including inside frontmatter values like `title:
   foo---bar`). Fixed to use `rest.find("\n---")` for line-boundary
   matching. This ensures accurate wordCount for the fallback heuristic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 17:58:47 +01:00
Test
6575ec2d1c fix: auto-save unsaved notes before trash/archive
Flush unsaved editor content to disk before any trash or archive
operation (both single-note and bulk) so body edits are never silently
dropped when only frontmatter is updated.

- Add flushEditorContent utility that checks pending content ref, then
  falls back to comparing tab content with last-saved state
- Add onBeforeAction callback to useEntryActions, called before
  handleTrashNote and handleArchiveNote
- Wire flushBeforeAction in App.tsx using refs for stable closures
- Add error handling in useBulkActions so one failed save doesn't
  block remaining notes
- Extract findOrCreateType helper to reduce useEntryActions complexity
- Export persistContent from useSaveNote for reuse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:52:08 +01:00
Test
1f08694e9d style: rustfmt assert formatting 2026-03-08 16:40:52 +01:00
Test
a99cb2af78 fix: parse lowercase 'archived' frontmatter field
The frontend writes 'archived: true' (lowercase) via handleUpdateFrontmatter,
but the Rust parser only recognized 'Archived' (titlecase). This caused all
notes archived from within Laputa to be read back as not archived — they
continued appearing in the sidebar and note list after restart.

Fix: add alias = "archived" to the serde attribute, matching the pattern
already used for 'trashed'/'Trashed'.

Regression tests added for both lowercase and titlecase variants.
2026-03-08 16:40:52 +01:00
Test
8eabcd9467 test: add Playwright smoke test for AI chat empty body fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:21:58 +01:00
Test
97112b9c84 fix: strip frontmatter from AI context body field — fixes empty body bug
The body field in buildContextSnapshot was passing the full raw file
content (including YAML frontmatter delimiters) instead of just the
body text. When handleEditorChange reconstructed tab content with empty
blocksToMarkdownLossy output, the body became frontmatter-only — causing
the AI to report "has frontmatter but no body content."

Three changes:
1. Strip frontmatter from body using splitFrontmatter before setting the
   body field (frontmatter is already a separate parsed field)
2. Add wordCount to the context snapshot so the AI can detect when body
   is stale vs genuinely empty
3. Instruct the AI to call get_note MCP tool when body is empty but
   wordCount > 0, providing a safety net for any content staleness

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:15:38 +01:00
Test
a53819e56a chore: extend .gitignore with runtime and generated artifacts
- .claude-pid: Claude Code runtime PID file, not repo content
- .laputa-index.json: generated search index, must not be committed
- *.key / *.key.pub: blanket guard against future signing key commits
2026-03-08 15:34:43 +01:00
Test
41a2d25311 chore: rotate Tauri signing keypair
Previous keypair was accidentally committed to git history.
New keypair generated, GitHub Secrets updated, pubkey rotated in tauri.conf.json.
Old key is now invalid for signing — any releases must use the new key.
2026-03-08 15:33:27 +01:00
Test
3a3d0bbcdf chore: remove stale files and planning docs from repo
- Remove CODE-HEALTH-REPORT.md, REDESIGN-PLAN.md, SF-SYMBOLS-MIGRATION.md (stale planning artifacts)
- Remove analyze_broken_links.py, select_demo_notes*.py, final_selection.py (demo-vault helper scripts)
- Remove screenshots/phase-*.png (old design screenshots)
- Remove __pycache__/ (Python bytecode)
- Remove (HOME)/.tauri/*.key from tracking (private signing keys — should never be in git)
- Update .gitignore to prevent future recurrence of all the above
2026-03-08 15:30:05 +01:00
Test
a4468289a2 fix: AI chat receives live editor content instead of stale disk content
handleContentChange now syncs content to tab state on every editor
change (not just on Cmd+S save). This ensures the AI panel's context
snapshot always contains the current editor body, fixing the bug where
the AI reported empty note bodies for unsaved edits.

Root cause: pendingContentRef buffered content for save but never
updated notes.tabs[].content, so activeTab?.content (used by the AI
context builder) always reflected the last-saved disk content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:07:45 +01:00
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
125 changed files with 4871 additions and 2093 deletions

View File

@@ -1 +0,0 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5V3BxWUNBZU1LZWxOK3ZEZTZkdGhzM2l6cnpVUmIvUEtTTWgzLzNEU1VoZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQUFpN2xxclpGK3YzRERub1EvZFdsdVdORktuOHZYVlB0S2U2QkhNdlMreElkRVdabTh6UllNYzNFb2VWYTVCayszNUpyOC94Z0dJS2pVQ3NSKzdKNEszZHpDZll0aGdtV1J6bWFXODc4VWJpOVFYdUhEai9tNHQ2U3ZRbVd1NHBkR01YS2ZrUDlPRVU9Cg==

View File

@@ -1 +0,0 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVCQTVDRkIzNkFGRTYwQjUKUldTMVlQNXFzOCtsVzROZmJLR0JFVGw1a1UzKzViY3dUcWFoaUttRFhhVk8rVUhrc29QL1FPeXUK

View File

@@ -1 +0,0 @@
91305

28
.gitignore vendored
View File

@@ -30,9 +30,6 @@ dist-ssr
# Coverage reports
/coverage/
# Laputa vault cache
.laputa-cache.json
# Demo vault and helper scripts
demo-vault/
select_demo_notes*.py
@@ -45,3 +42,28 @@ src-tauri/target
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
src-tauri/resources/
# Python cache
__pycache__/
*.py[cod]
# Dev screenshots
screenshots/
# Stale planning docs (keep locally if needed, not in repo)
REDESIGN-PLAN.md
SF-SYMBOLS-MIGRATION.md
CODE-HEALTH-REPORT.md
# Local home dir artifact from worktree ops
(HOME)/
# Runtime / process files
.claude-pid
# Generated vault index files (qmd/search artifacts)
.laputa-index.json
# Tauri signing keys (never commit private keys)
*.key
*.key.pub

View File

@@ -36,15 +36,50 @@ BASE_URL="http://localhost:<N>" pnpm playwright:smoke
kill $DEV_PID
```
**What to test in Playwright:**
- Every command palette entry from the spec → open `Cmd+K`, type the command name, verify it appears and executes
**You must write a new Playwright test for this task** in `tests/smoke/<slug>.spec.ts` that covers every acceptance criterion. Do not rely only on existing smoke tests — they test the app in general, not your specific feature.
**What to cover in your Playwright test:**
- Every acceptance criterion from the task spec → one `test()` block per criterion
- Every command palette entry → open `Cmd+K`, type the command name, verify it appears and executes
- Every keyboard shortcut → send keydown events, verify UI state changes
- Every UI element described in the spec → verify it renders, is focusable, responds to Tab
- Edge cases: empty state, long text, rapid keypresses
- **The happy path end-to-end**: simulate exactly what a user would do to use this feature
**Playwright is non-negotiable even if tests pass.** Unit tests verify code; Playwright verifies the user experience in the real browser. Both are required.
**The test must fail before your fix and pass after.** If you can't write a test that demonstrates the bug is fixed, your test doesn't cover the right thing.
> **⚠️ Browser dev server limits**: the dev server uses mock Tauri handlers (`src/mock-tauri.ts`) — file system operations, git commands, and native dialogs are mocked. Test those via `pnpm tauri dev` in Phase 2 if the task touches them.
**Playwright is non-negotiable even if unit tests pass.** Unit tests verify code; Playwright verifies the user experience in the real browser. Both are required.
> **⚠️ Browser dev server limits**: the dev server uses mock Tauri handlers (`src/mock-tauri.ts`) — file system operations, git commands, and native dialogs are mocked. **If your task touches the filesystem, AI context pipeline, MCP server, git integration, or any Tauri command that reads/writes real files, Playwright alone is not enough.** You must also do Phase 1b.
### Phase 1b: Tauri dev QA (you do this for filesystem/native tasks)
If your task touches **any of the following**, you must also test with `pnpm tauri dev` against the real vault before firing done:
- File read/write (notes, cache, vault config)
- AI chat context (what the AI actually receives as input)
- MCP server / subprocess communication
- Git integration (commit, push, history, diff)
- Native dialogs or OS-level features
```bash
# Start Tauri dev app from your worktree
pnpm tauri dev --port <N> &
sleep 10 # wait for Tauri + Vite to boot
# Then test using osascript keyboard events (NO mouse/cliclick)
# Example:
osascript -e 'tell application "laputa" to activate'
osascript -e 'tell application "System Events" to keystroke "k" using command down'
# ...simulate the full user flow from the acceptance criteria
```
**What to verify in Phase 1b:**
- Open the feature on a real note in `~/Laputa` (not the demo vault)
- Walk through every acceptance criterion step by step using keyboard only
- Verify file changes with `git -C ~/Laputa diff` if the task writes files
- Verify AI responses actually contain note content (not empty) if the task touches AI context
**⚠️ Claude Code runs headless — you cannot see the screen.** Use `screencapture /tmp/qa-check.png` and then read/describe what you see if you need visual verification. Or rely on DOM state checks via osascript accessibility API.
### Phase 2: Native Tauri QA (Brian does this after you push)
@@ -64,6 +99,9 @@ Brian installs the release build and runs keyboard-only QA on the native app. Yo
- Every QA comment must include: the exact keyboard/command palette steps used, what was visible before and after, and any edge case tested.
- If you cannot test a feature using keyboard only (osascript shortcuts + command palette), the feature is not keyboard-first → QA fails.
**⚠️ Phase 1 is YOUR quality gate, not a formality.**
Brian's Phase 2 QA is a *reinforcement* check, not the primary gate. If Brian finds a bug in Phase 2 that you could have caught in Phase 1, that is a Phase 1 failure — not a Phase 2 discovery. Before firing the done signal, ask yourself: "Did I actually verify, in Playwright, that the feature works end-to-end exactly as the spec describes?" If the answer is "I ran the smoke tests and they passed", that is not enough. You must run your task-specific test and verify the acceptance criteria one by one.
**⚠️ Test in a clean environment when the feature depends on state.**
If a feature involves indexing, fresh installs, first-time setup, or anything that only runs once:
- **Do not test in the existing dev vault** — it already has the state you're trying to test.

View File

@@ -1,345 +0,0 @@
# Code Health Report — Laputa App
**Date:** 2026-02-20
**Branch:** `main`
**Overall Project Score:** 9.33 / 10.0 (Green — up from 9.14)
**Tool:** CodeScene Code Health Analysis (project ID: 76865)
**Previous Report:** 2026-02-20 on `main` — 9.14 / 10.0
---
## Summary
The Laputa App codebase scores **9.33** overall — a further improvement of **+0.19** from the previous report (9.14). The codebase remains solidly in **Green**, driven by the `vault.rs` refactoring (+2.59 to 8.81) and the `frontmatter.rs` refactoring (+2.79 to 9.68, now Green). Five files remain in the Yellow zone, down from six — `frontmatter.rs` has exited Yellow into Green.
| Zone | Score Range | File Count | Description |
|------|------------|------------|-------------|
| Optimal | 10.0 | 8 | Perfect — optimized for human and AI comprehension |
| Green | 9.0 9.9 | 15 | High quality, minor issues only |
| Yellow | 4.0 8.9 | 5 | Problematic technical debt |
| Red | 1.0 3.9 | 0 | — |
| N/A | — | 6 | CSS files (4) and tiny utility files (2) — unsupported by CodeScene |
---
## Refactoring Completed (vault.rs + frontmatter.rs)
The following refactorings were executed on vault.rs and frontmatter.rs, raising both files significantly:
### vault.rs: 6.22 → 8.81 (+2.59)
Refactored in 5 commits across multiple phases:
1. **Extracted `run_git` helper** — Consolidated duplicated git command execution into a single helper function, flattening git functions (`git_changed_files`, `git_uncommitted_new_files`).
2. **Decomposed `parse_md_file`** — Extracted `parse_frontmatter_fields`, `extract_title`, `extract_snippet`, and `extract_relationships` into focused sub-functions. Flattened deep nesting with early returns.
3. **Decomposed `scan_vault_cached`** — Extracted `process_vault_entry`, `collect_vault_entries`, `apply_git_status`, and `build_vault_response` as focused functions.
4. **Split large test assertion blocks** — Broke monolithic assertion blocks into per-field assertions for readability and maintainability.
5. **Converted internal functions to use `&Path`** instead of `&str` for vault/file paths, reducing string-heavy arguments.
All 8 original code smells (3 Bumpy Roads, 4 Deep Nestings, 2 Complex Methods, 2 Large Methods, String-Heavy Args, Large Assertion Blocks) have been resolved. The CodeScene review now reports **zero code smells**.
### frontmatter.rs: 6.89 → 9.68 (+2.79) — Yellow → Green
Refactored in 4 commits:
1. **Flattened `update_frontmatter_content`** — Used early returns and extracted `find_key_line_range` and `build_updated_content` helpers. Eliminated bumpy road (4 bumps) and deep nesting (4 levels).
2. **Simplified `FrontmatterValue::to_yaml_value`** — Extracted `needs_yaml_quoting` predicate, simplified match arms. Reduced cc from 17.
3. **Simplified `format_yaml_key`** — Extracted key-quoting rules into `key_needs_quoting` predicate. Reduced complex conditionals from 5.
4. **Extracted line-parsing helpers**`line_is_key` and related helpers for clean YAML line detection.
All original code smells (1 Bumpy Road, 1 Deep Nesting, 2 Complex Methods, 4 Complex Conditionals) have been resolved. Only one minor issue remains: **String Heavy Function Arguments** (73% of args are string types).
---
## Change Summary vs Previous Report (Feb 17)
| File | Previous | Current | Delta | Notes |
|------|----------|---------|-------|-------|
| `src/App.tsx` | 7.13 | **9.28** | **+2.15** | Yellow -> Green. Brain Method eliminated via hook extraction |
| `src/components/Inspector.tsx` | 7.49 | **9.02** | **+1.53** | Yellow -> Green. Decomposed into sub-components |
| `src-tauri/src/vault.rs` | 4.80 | **8.81** | **+4.01** | Still Yellow but near-Green. All code smells resolved |
| `src-tauri/src/frontmatter.rs` | 6.89* | **9.68** | **+2.79** | Yellow -> Green. All major smells resolved |
| `src/components/Editor.tsx` | 6.94 | **7.68** | **+0.74** | Still Yellow. DiffView/wikilinks extracted but Editor still too large |
| `src/components/Sidebar.tsx` | 9.02 | **9.14** | +0.12 | Green (stable) |
| `src/components/NoteList.tsx` | 8.11 | **8.05** | -0.06 | Yellow (stable, slight regression) |
| `src/components/QuickOpenPalette.tsx` | 9.55 | **9.55** | = | Green (unchanged) |
| `src-tauri/src/lib.rs` | 9.68 | **9.68** | = | Green (unchanged) |
| `src-tauri/src/main.rs` | 10.0 | **10.0** | = | Optimal (unchanged) |
| `src-tauri/src/git.rs` | 10.0 | **10.0** | = | Optimal (unchanged) |
| `src/components/StatusBar.tsx` | 10.0 | **9.23** | -0.77 | Regression: Optimal -> Green (new features added) |
| `src/mock-tauri.ts` | 10.0 | **9.37** | -0.63 | Regression: Optimal -> Green (new mock data added) |
*frontmatter.rs was extracted from vault.rs; "previous" is its initial score after extraction.
### New Files (not in previous report)
| File | Score | Zone | Notes |
|------|-------|------|-------|
| `src/hooks/useNoteActions.ts` | **7.81** | Yellow | Extracted from App.tsx — still needs decomposition |
| `src/components/AIChatPanel.tsx` | **8.51** | Yellow | New feature — large component |
| `src/components/DynamicPropertiesPanel.tsx` | **9.06** | Green | Extracted from Inspector.tsx |
| `src/components/DiffView.tsx` | **9.09** | Green | Extracted from Editor.tsx |
| `src/utils/frontmatter.ts` | **9.24** | Green | Extracted from Inspector.tsx |
| `src/components/CommitDialog.tsx` | **9.38** | Green | New component |
| `src/hooks/useVaultLoader.ts` | **9.41** | Green | Extracted from App.tsx |
| `src/utils/wikilinks.ts` | **9.53** | Green | Extracted from Editor.tsx |
| `src/hooks/useTheme.ts` | **9.68** | Green | New hook |
| `src/components/EditableValue.tsx` | **10.0** | Optimal | Extracted from Inspector.tsx |
| `src/components/ResizeHandle.tsx` | **10.0** | Optimal | New component |
| `src/components/CreateNoteDialog.tsx` | **10.0** | Optimal | New component |
| `src/components/Toast.tsx` | **10.0** | Optimal | New component |
| `src/utils/typeColors.ts` | **10.0** | Optimal | New utility |
| `src/main.tsx` | **10.0** | Optimal | Entry point |
---
## File-by-File Scores (All 34 Files)
| File | LoC | Score | Zone | Key Issues |
|------|-----|-------|------|------------|
| `src-tauri/src/main.rs` | 6 | **10.0** | Optimal | None |
| `src-tauri/src/git.rs` | 423 | **10.0** | Optimal | None |
| `src/components/EditableValue.tsx` | 167 | **10.0** | Optimal | None |
| `src/components/ResizeHandle.tsx` | 74 | **10.0** | Optimal | None |
| `src/components/CreateNoteDialog.tsx` | 99 | **10.0** | Optimal | None |
| `src/components/Toast.tsx` | 28 | **10.0** | Optimal | None |
| `src/utils/typeColors.ts` | 37 | **10.0** | Optimal | None |
| `src/main.tsx` | 16 | **10.0** | Optimal | None |
| `src-tauri/src/frontmatter.rs` | 279 | **9.68** | Green | String-heavy function arguments (73%) |
| `src-tauri/src/lib.rs` | 80 | **9.68** | Green | String-heavy function arguments |
| `src/hooks/useTheme.ts` | 51 | **9.68** | Green | None significant |
| `src/components/QuickOpenPalette.tsx` | 145 | **9.55** | Green | Complex Method (cc=16) |
| `src/utils/wikilinks.ts` | 68 | **9.53** | Green | None significant |
| `src/hooks/useVaultLoader.ts` | 123 | **9.41** | Green | None significant |
| `src/components/CommitDialog.tsx` | 73 | **9.38** | Green | None significant |
| `src/mock-tauri.ts` | 894 | **9.37** | Green | None significant |
| `src/App.tsx` | 176 | **9.28** | Green | Complex Method: App() cc=16 / 130 LoC |
| `src/utils/frontmatter.ts` | 72 | **9.24** | Green | None significant |
| `src/components/StatusBar.tsx` | 159 | **9.23** | Green | None significant |
| `src/components/Sidebar.tsx` | 208 | **9.14** | Green | None significant |
| `src/components/DiffView.tsx` | 45 | **9.09** | Green | None significant |
| `src/components/DynamicPropertiesPanel.tsx` | 265 | **9.06** | Green | None significant |
| `src/components/Inspector.tsx` | 312 | **9.02** | Green | None significant |
| `src-tauri/src/vault.rs` | 1111 | **8.81** | Yellow | No code smells reported — near Green threshold |
| `src/components/AIChatPanel.tsx` | 364 | **8.51** | Yellow | Complex Method: AIChatPanel() cc=15 / 285 LoC |
| `src/components/NoteList.tsx` | 434 | **8.05** | Yellow | Complex Method: NoteListInner() cc=28 / 208 LoC |
| `src/hooks/useNoteActions.ts` | 280 | **7.81** | Yellow | Bumpy Road, Deep Nesting, Complex Method: useNoteActions() cc=30 / 169 LoC |
| `src/components/Editor.tsx` | 575 | **7.68** | Yellow | **Brain Method**: Editor() cc=61 / 385 LoC, Bumpy Road |
| `src/types.ts` | 38 | N/A | — | Type definitions only |
| `src/lib/utils.ts` | 6 | N/A | — | Utility (too small) |
| `src/App.css` | — | N/A | — | CSS not supported |
| `src/index.css` | — | N/A | — | CSS not supported |
| `src/components/Editor.css` | — | N/A | — | CSS not supported |
| `src/components/EditorTheme.css` | — | N/A | — | CSS not supported |
---
## Technical Debt Hotspots
Based on code health scores, file sizes, and change frequency:
| Priority | File | Score | LoC | Risk Factor |
|----------|------|-------|-----|-------------|
| 1 | `src/components/Editor.tsx` | 7.68 | 575 | **Brain Method** (cc=61, 385 LoC) — worst single function in codebase |
| 2 | `src/hooks/useNoteActions.ts` | 7.81 | 280 | Brain Method (cc=30, 169 LoC), deep nesting in updateMockFrontmatter |
| 3 | `src/components/NoteList.tsx` | 8.05 | 434 | Complex Method (cc=28, 208 LoC) |
| 4 | `src/components/AIChatPanel.tsx` | 8.51 | 364 | Large component (cc=15, 285 LoC) — new, address before it grows |
| 5 | `src-tauri/src/vault.rs` | 8.81 | 1111 | Near-Green, no code smells — minor improvement needed to cross 9.0 |
---
## Detailed Analysis — Files Scoring Below 9.0
### 1. `src/components/Editor.tsx` — Score: 7.68 (Now #1 Priority)
The core `Editor` component function remains a **Brain Method** — the single worst function in the codebase at cc=61 and 385 LoC (3.2x the 120 LoC limit).
**Code Smells Found:**
| Smell | Location | Details | Severity |
|-------|----------|---------|----------|
| Bumpy Road | `Editor` (L154575) | 2 bumps | High |
| Complex Method | `Editor` (L154575) | cc = 61 (**Brain Method**) | High |
| Complex Conditional | `Editor:196` | 2 complex expressions | Medium |
| Large Method | `Editor` (L154575) | 385 LoC (limit: 120) | Medium |
---
### 2. `src/hooks/useNoteActions.ts` — Score: 7.81
Extracted from App.tsx. Contains the `updateMockFrontmatter` function which has deep nesting, plus the `useNoteActions` hook itself is still too large.
**Code Smells Found:**
| Smell | Location | Details | Severity |
|-------|----------|---------|----------|
| Bumpy Road | `updateMockFrontmatter` (L1466) | 2 bumps | High |
| Deep Nesting | `updateMockFrontmatter` (L1466) | 4 levels deep | High |
| Complex Method | `useNoteActions` (L93280) | cc = 30 | Medium |
| Complex Method | `updateMockFrontmatter` (L1466) | cc = 17 | Medium |
| Complex Method | `deleteMockFrontmatterProperty` (L6891) | cc = 9 | Medium |
| Large Method | `useNoteActions` (L93280) | 169 LoC (limit: 70) | Medium |
---
### 3. `src/components/NoteList.tsx` — Score: 8.05
Slightly regressed from 8.11. The `NoteListInner` component and `buildRelationshipGroups` remain complex.
**Code Smells Found:**
| Smell | Location | Details | Severity |
|-------|----------|---------|----------|
| Complex Method | `NoteListInner` (L211432) | cc = 28 | Medium |
| Complex Method | `buildRelationshipGroups` (L125188) | cc = 13 | Medium |
| Large Method | `NoteListInner` (L211432) | 208 LoC (limit: 120) | Medium |
| Overall Code Complexity | File-wide | High mean cyclomatic complexity | Medium |
---
### 4. `src/components/AIChatPanel.tsx` — Score: 8.51
New file (mock AI chat feature). Already showing signs of complexity that should be addressed early.
**Code Smells Found:**
| Smell | Location | Details | Severity |
|-------|----------|---------|----------|
| Complex Method | `AIChatPanel` (L62364) | cc = 15 | Medium |
| Large Method | `AIChatPanel` (L62364) | 285 LoC (limit: 120) | Medium |
---
### 5. `src-tauri/src/vault.rs` — Score: 8.81
Dramatically improved from 6.22. The CodeScene review reports **zero code smells** after the refactoring. The file is near the Green threshold (9.0) and may only need minor adjustments to cross it.
---
## Quick Wins (Low Effort, High Impact)
### 1. Decompose `Editor` into hooks (highest ROI)
**File:** `src/components/Editor.tsx` | **Impact:** cc 61 -> ~10 per hook
- Extract `useEditorExtensions()` — all CodeMirror extension setup (themes, keybindings, decorations)
- Extract `useEditorContent()` — content loading, saving, dirty state management
- Extract `useEditorKeymap()` — custom keymap handlers
- The `Editor` component becomes a thin composition + JSX layer
### 2. Decompose `useNoteActions` hook
**File:** `src/hooks/useNoteActions.ts` | **Impact:** cc 30 -> ~8 per hook
- Extract `useFrontmatterSync()``updateMockFrontmatter` + `deleteMockFrontmatterProperty`
- Flatten `updateMockFrontmatter` with early returns and helper functions
- Keep `useNoteActions` as pure action dispatch (create, delete, rename)
### 3. Split `NoteListInner` into sub-components
**File:** `src/components/NoteList.tsx` | **Impact:** cc 28 -> ~8 per component
- Extract `NoteListItem` component for individual note rendering
- Extract `RelationshipGroup` component for grouped entries
- Extract `buildRelationshipGroups` to a utility file
### 4. Extract `AIChatPanel` hooks early
**File:** `src/components/AIChatPanel.tsx` | **Impact:** Prevent further complexity growth
- Extract `useChatMessages()` — message state, send/receive logic
- Extract `ChatMessage` component for individual message rendering
### 5. Push `vault.rs` past 9.0
**File:** `src-tauri/src/vault.rs` | **Impact:** 8.81 -> 9.0+
- Minor: reduce string-heavy args further with `&Path` conversions
- Minor: simplify any remaining complex expressions
---
## Path to 9.5 Overall
**Current:** 9.33 (28 scored files, sum = 261.20)
**Target:** 9.5
To reach 9.5, all 5 Yellow files must reach at least 9.5:
| File | Current | Target | Points Needed |
|------|---------|--------|---------------|
| `vault.rs` | 8.81 | 9.5 | +0.69 |
| `Editor.tsx` | 7.68 | 9.5 | +1.82 |
| `useNoteActions.ts` | 7.81 | 9.5 | +1.69 |
| `NoteList.tsx` | 8.05 | 9.5 | +1.45 |
| `AIChatPanel.tsx` | 8.51 | 9.5 | +0.99 |
| **Total points needed** | | | **+6.64** |
**Projected score if all Yellow files reach 9.5:** (261.20 + 6.64) / 28 = **9.57**
**Recommended execution order for maximum impact:**
1. `Editor.tsx` (7.68 -> 9.5) — highest user-facing impact, hook extraction is mechanical
2. `useNoteActions.ts` (7.81 -> 9.5) — extracted hook, straightforward decomposition
3. `NoteList.tsx` (8.05 -> 9.5) — component extraction
4. `AIChatPanel.tsx` (8.51 -> 9.5) — closest to target, prevent drift
5. `vault.rs` (8.81 -> 9.5) — near-Green already, minor tweaks
---
## Refactoring ROI Summary
| File | Current | Target | Defect Reduction | Speed Improvement |
|------|---------|--------|------------------|-------------------|
| `Editor.tsx` | 7.68 | 9.5 | 2538% | 1930% |
| `useNoteActions.ts` | 7.81 | 9.5 | 2436% | 1828% |
| `NoteList.tsx` | 8.05 | 9.5 | 2233% | 1626% |
| `AIChatPanel.tsx` | 8.51 | 9.5 | 1827% | 1321% |
| `vault.rs` | 8.81 | 9.5 | 1018% | 814% |
---
## Files in Good Shape
These files need no immediate attention:
**Optimal (10.0):**
- `src-tauri/src/main.rs` — 6 LoC, clean entry point
- `src-tauri/src/git.rs` — 423 LoC, well-structured
- `src/components/EditableValue.tsx` — 167 LoC, clean extracted component
- `src/components/ResizeHandle.tsx` — 74 LoC, simple component
- `src/components/CreateNoteDialog.tsx` — 99 LoC, clean dialog
- `src/components/Toast.tsx` — 28 LoC, minimal component
- `src/utils/typeColors.ts` — 37 LoC, simple utility
- `src/main.tsx` — 16 LoC, entry point
**Green (9.09.9):**
- `src-tauri/src/frontmatter.rs` — 9.68 (up from 6.89! Only: string-heavy args)
- `src-tauri/src/lib.rs` — 9.68 (minor: string-heavy args)
- `src/hooks/useTheme.ts` — 9.68 (clean hook)
- `src/components/QuickOpenPalette.tsx` — 9.55 (minor: cc=16)
- `src/utils/wikilinks.ts` — 9.53 (clean utility)
- `src/hooks/useVaultLoader.ts` — 9.41 (clean hook)
- `src/components/CommitDialog.tsx` — 9.38 (clean component)
- `src/mock-tauri.ts` — 9.37 (large but clean)
- `src/App.tsx` — 9.28 (dramatically improved from 7.13)
- `src/utils/frontmatter.ts` — 9.24 (clean utility)
- `src/components/StatusBar.tsx` — 9.23 (slightly regressed from 10.0)
- `src/components/Sidebar.tsx` — 9.14 (stable)
- `src/components/DiffView.tsx` — 9.09 (clean extracted component)
- `src/components/DynamicPropertiesPanel.tsx` — 9.06 (clean extracted component)
- `src/components/Inspector.tsx` — 9.02 (dramatically improved from 7.49)
---
## What Worked Since Last Report
The following refactorings from the Feb 17 recommendations were executed:
1. **App.tsx decomposition** (Plan C) — Extracted `useNoteActions`, `useVaultLoader`, and other hooks. App dropped from cc=56/381 LoC to cc=16/130 LoC. Score: 7.13 -> 9.28.
2. **Inspector.tsx decomposition** (Plan D) — Extracted `DynamicPropertiesPanel`, `EditableValue`, and `frontmatter.ts` utility. Score: 7.49 -> 9.02.
3. **vault.rs full refactoring** (Plan A) — Extracted `run_git` helper, decomposed `parse_md_file` and `scan_vault_cached`, split large assertion blocks, converted to `&Path` args. Score: 4.80 -> 8.81. **All code smells resolved.**
4. **frontmatter.rs full refactoring** (Plan B) — Flattened `update_frontmatter_content`, simplified `to_yaml_value` and `format_yaml_key`, extracted line-parsing helpers. Score: 6.89 -> 9.68. **Yellow -> Green.**
5. **Editor.tsx partial decomposition** (Plan B, Steps 23) — Extracted `DiffView.tsx` and `wikilinks.ts`. Score: 6.94 -> 7.68.
## What Still Needs Work
1. **Editor.tsx** — DiffView and wikilinks were extracted, but the core Editor function was NOT decomposed into hooks. It's now the worst function (cc=61, 385 LoC). Hook extraction (useEditorExtensions, useEditorContent, useEditorKeymap) is the next high-impact target.
2. **useNoteActions.ts** — Inherited App.tsx's `updateMockFrontmatter` complexity. Needs decomposition into smaller hooks.
3. **NoteList.tsx** — Slight regression, needs component extraction (NoteListItem, RelationshipGroup).
4. **AIChatPanel.tsx** — New file already showing complexity. Address early before it grows.
5. **vault.rs** — Near-Green at 8.81 with zero code smells. Minor tweaks may push it past 9.0.
---
*Report generated by CodeScene MCP analysis on 2026-02-20. For interactive exploration, visit: https://codescene.io/projects/76865*
*Note: CodeScene MCP Server MCP-0.1.5 was used. Version MCP-0.2.0 is available — consider updating via `brew upgrade cs-mcp`.*

View File

@@ -1,362 +0,0 @@
# Laputa App Redesign — Implementation Plan
> Generated from `ui-design.pen` (V2) vs current implementation. **Analysis only — do not implement yet.**
---
## Summary of Changes
The V2 design introduces: a **Status Bar**, **Tab Bar** in the editor, an **Info Bar** (breadcrumb + actions), restructured **Sidebar** with Phosphor icons and collapsible groups with count badges, **IBM Plex Mono** for type pills, updated **color palette** (new primary `#155DFF`, new accent colors), and several layout/spacing refinements throughout.
---
## Design Specs Reference (from .pen file)
### Colors Changed
| Variable | Old (Light) | New (Light) | Old (Dark) | New (Dark) |
|---|---|---|---|---|
| `--primary` | `#2383E2` | `#155DFF` | `#4a9eff` | `#155DFF` |
| `--accent-green` | `#0F7B6C` | `#00B38B` | `#4caf50` | `#00B38B` |
| `--accent-purple` | `#9065B0` | `#A932FF` | `#9c72ff` | `#A932FF` |
| `--accent-blue` | `#2383E2` | `#155DFF` | `#4a9eff` | `#155DFF` |
### New Color Variables (not in current CSS)
| Variable | Light | Dark |
|---|---|---|
| `--accent-yellow` | `#F0B100` | `#F0B100` |
| `--accent-blue-light` | `#155DFF14` | `#155DFF20` |
| `--accent-green-light` | `#00B38B14` | `#00B38B20` |
| `--accent-purple-light` | `#A932FF14` | `#A932FF20` |
| `--accent-red-light` | `#E03E3E14` | `#f4433620` |
| `--accent-yellow-light` | `#F0B10014` | `#F0B10020` |
### Typography
- **Font**: Inter (primary), IBM Plex Mono (labels/pills) — **IBM Plex Mono not currently loaded**
- App title: 17px / Bold / letter-spacing -0.3
- Sidebar items: 13px / Medium (font-weight 500)
- Sidebar section headers: 13px / Semibold (600) — currently 11px
- Type pills: 11px / IBM Plex Mono / normal weight / ALL CAPS
- Editor H1: 32px / Bold / lh 1.2
- Editor H2: 24px / Semibold / lh 1.3
- Editor body: 16px / Regular / lh 1.6
- Info bar / breadcrumb: 12px
- Status bar: 11px
### Panel Widths
| Panel | Design | Current |
|---|---|---|
| Sidebar | 250px | 250px ✅ |
| NoteList | 300px | 300px ✅ |
| Editor | flexible | flexible ✅ |
| Inspector | 260px (design) / 280px (spec) | 280px ✅ |
### Border Radius Scale
- 4px (sm) — chips
- 6px (md) — buttons, inputs
- 8px (lg) — cards, dialogs
- 9999px — pills, badges (full-round)
- 16px — larger badges
---
## Difference Map
### 1. NEW: Status Bar (bottom of app)
**Files**: New component `StatusBar.tsx`, `App.tsx`, `App.css`
- 30px height, `bg: --sidebar`, `border-top: 1px --border`
- **Left**: box icon + "v0.4.2" | git-branch + "main" | refresh-cw (green) + "Synced 2m ago"
- **Right**: sparkles (purple) + "Claude Sonnet 4" | file-text + "1,247 notes" | bell icon | settings icon
- Padding: 0 8px, items aligned center, gap 12px between items
- Font: Inter 11px, text color `--muted-foreground`
- Separators: "|" in `--border` color
- **All icons**: Lucide, 13-14px
### 2. NEW: Tab Bar (top of editor panel)
**Files**: `Editor.tsx`
- 45px height, `bg: --sidebar`, `border-bottom: 1px --sidebar-border`
- **Active tab**: `bg: --background`, border-right 1px `--border`, text 12px/500 `--foreground`, X close icon (14px lucide)
- **Inactive tab**: no fill, border-right + border-bottom 1px `--sidebar-border`, text 12px/normal `--muted-foreground`, X icon opacity 0 (show on hover)
- **Spacer**: fills remaining width, border-bottom 1px `--border`
- **Controls area** (right): border-left + border-bottom 1px `--border`, gap 12px, padding 0 12px
- Plus icon (Phosphor, 16px)
- Columns/split icon (Phosphor, 16px) — **disabled placeholder**
- Arrows-out-simple/expand icon (Phosphor, 16px) — **disabled placeholder**
### 3. NEW: Breadcrumb Bar (below tab bar, above editor content)
**Files**: `Editor.tsx`
- 45px height, `bg: --background`, `border-bottom: 1px --border`
- Padding: 6px 16px
- **Left (breadcrumb)**: "Project" (12px, muted) "Laputa App" (12px/500, foreground) · "1,284 words" (12px, muted) · "M" (12px/600, `--accent-yellow`) — M only when file modified
- **Right (actions)**: gap 12px, each 16px Phosphor icon in `--muted-foreground`
- magnifying-glass (search in file)
- git-branch (version history) — **disabled placeholder**
- cursor-text (focus mode) — **disabled placeholder**
- sparkle (AI assist) — **disabled placeholder**
- dots-three (more options) — **disabled placeholder**
### 4. Sidebar Restructure
**Files**: `Sidebar.tsx`
#### Header changes:
- Current: "Laputa" title + theme toggle button
- New: "Laputa" title (17px/700, -0.3 ls) + search icon (16px Phosphor magnifying-glass) + settings/gear icon (16px)
- Theme toggle moved elsewhere (or removed from header)
- Padding: 12px 16px, height 45px, border-bottom 1px
#### Search bar added:
- Below header, padding 6px 12px, border-bottom 1px
- Input with magnifying-glass icon prefix, 13px text, placeholder "Search notes..."
- Height ~32px, border-radius 6px, bg `--secondary`
#### Navigation section restructured:
**Current**: flat list of filters (All Notes, People, Events, Changes, Favorites, Trash)
**New**: Two items in top nav:
- "All Notes" — file-text icon (Phosphor 16px) + label 13px/500 + count badge (pill, bg `--secondary`, 10px text)
- "Favorites" — star icon (Phosphor 16px) + same style
#### Section groups restructured:
**Current**: PROJECTS, EXPERIMENTS, RESPONSIBILITIES, PROCEDURES as expandable sections with items listed under each
**New**: Collapsible groups with consistent pattern:
- Each group: chevron-right (12px Lucide) + icon (18px Phosphor, bold) + label (13px/600) + count badge (pill)
- **Projects** — folder-open icon (Phosphor)
- **Experiments** — flask icon (Phosphor)
- **Responsibilities** — target icon (Phosphor) — **currently not in sidebar**
- **Procedures** — arrows-clockwise icon (Phosphor)
- **People** — users icon (Phosphor) — **moved from filter to section group**
- **Events** — calendar-blank icon (Phosphor) — **moved from filter to section group**
- **Topics** — tag icon (Phosphor) — **currently at bottom, now integrated as a group**
Each group has:
- Container: padding 4px 6px, border-bottom 1px (disabled in some), vertical layout, gap 2px
- Header row: padding 6px 16px, corner-radius 4px, gap 8px, justify space-between
- Badge: height 20px, bg `--secondary`, corner-radius 9999px, padding 0 6px
#### Removed from sidebar:
- "Untagged" filter — not in new design
- "Changes" filter — not in new design (modified files shown elsewhere)
- "Trash" filter — not in new design
- "People" as top-level filter — now a collapsible section group
- "Events" as top-level filter — now a collapsible section group
#### Commit button:
- Same concept but refined: padding 12px, border-top 1px
- Button: fill `--primary`, corner-radius 6px, gap 6px, padding 8px 16px
- Icon: git-commit-horizontal (Lucide 14px) in `--primary-foreground`
- Text: "Commit & Push" (13px/500)
- Badge: bg `#ffffff40`, corner-radius 9px, text `--white` 10px/600
### 5. NoteList Changes
**Files**: `NoteList.tsx`
#### Header:
- Current: title + count badge + create button
- New: "Notes" title (14px/600) + search icon (16px Phosphor) + plus icon (16px Phosphor) — gap 12px
- No separate count badge in header
#### Search:
- Current: always-visible search input below header
- New: search icon in header (search may toggle inline or use command palette)
- **Remove the always-visible search input** or keep it hidden until search icon clicked
#### Type pills:
- Current: rounded-full, border, `text-[11px]`, system font, "Projects 4" format
- New: `IBM Plex Mono` font, 11px, ALL CAPS format "ALL 24" / "PROJECTS 4" / "NOTES 12" / "EVENTS 5"
- Active pill: `bg: #4a9eff18` (blue tint), `border: 1px --primary`, text `--primary`
- Inactive pill: `border: 1px --border`, text `--muted-foreground`
- Pill padding: 2px 10px, corner-radius 9999px
- Height: ~18px (compact)
- Layout: absolute positioned at x offsets (12, 76, 166, 243) within 45px height container — effectively a horizontal scrollable row
#### Note items:
- Selected: `bg: #2383E212` (very light blue), left accent bar 3px `#2383E2`, title 13px/600
- Normal: border-bottom 1px `#E9E9E7`, title 13px/500, time 11px, snippet 12px/lh1.5
- Padding: 10px 16px
- **No type badge** on individual items (simplified)
- **No status text** on items
### 6. Editor Content Area
**⚠️ SKIP — Keep editor as-is. Editor changes in the design are NOT intentional.**
### 7. Inspector Refinements
**Files**: `Inspector.tsx`
#### Header:
- Current: collapsed toggle + title
- New: sliders-horizontal icon (16px Phosphor) + "Properties" (13px/600, `--muted-foreground`) + X close button (16px Phosphor)
- Height 45px, border-bottom 1px, padding 0 12px, gap 8px
#### Properties section:
- Key-value rows: label (12px, muted) — value (12px, foreground), space-between
- Status badge: colored bg (e.g., `--accent-green-light`) with colored text (e.g., `--accent-green`), rounded, padding 1px 6px, 10px font
- "+ Add property" button: full-width, border 1px `--border`, corner-radius 6px, padding 6px 12px, centered text (12px, muted)
#### Relationships section:
- Group title: 12px/600 foreground
- Link buttons: full-width, bg `--accent-blue-light`, corner-radius 6px, padding 6px 10px, text `--primary` 12px/500, icon (tag/flask, Phosphor 14px, 0.5 opacity)
- "+ Link existing" button: border 1px `--border`, corner-radius 6px, same padding
#### Backlinks:
- Title: "Backlinks" 12px/600 + count 11px/500 muted
- Items: text `--primary` 12px
#### History:
- Title: "History" 12px/600
- Items: left border 2px `--border`, padding-left 10px
- Hash line: 11px foreground
- Date line: 10px muted
### 8. Icon Library Change
**Current**: Lucide React throughout
**New**: **SF Symbols** (Apple's native icon set) for all new/redesigned icons. Use `sf-symbols-react` or inline SVGs extracted from SF Symbols app.
**Note**: The Pencil design used Phosphor as a placeholder — Luca's intent is SF Symbols throughout. Map Phosphor names to SF Symbol equivalents:
- `magnifying-glass``magnifyingglass`
- `star``star.fill`
- `folder-open``folder`
- `flask``flask`
- `target``target`
- `arrows-clockwise``arrow.clockwise`
- `users``person.2`
- `calendar-blank``calendar`
- `tag``tag`
- `plus``plus`
- `columns``rectangle.split.2x1`
- `arrows-out-simple``arrow.up.left.and.arrow.down.right`
- `sliders-horizontal``slider.horizontal.3`
- `cursor-text``character.cursor.ibeam`
- `sparkle``sparkles`
- `dots-three``ellipsis`
- `git-branch``arrow.triangle.branch`
- `gear``gearshape`
**Action**: Find the best approach for SF Symbols in React/Tauri (e.g., `sf-symbols-react`, SVG extraction, or native font)
---
## Implementation Phases
### Phase 1: Theme & Typography Updates
**Scope**: CSS variables, fonts, colors — no structural changes
**Files**: `src/index.css`, `index.html` (or font import)
**Estimated effort**: 1 Claude Code session
1. **Add IBM Plex Mono font** — add Google Fonts import or npm package
2. **Update color variables in `index.css`**:
- `:root` (light): `--primary: #155DFF`, `--accent-green: #00B38B`, `--accent-purple: #A932FF`, `--accent-blue: #155DFF`
- `.dark`: same primary `#155DFF`, accent-green `#00B38B`, accent-purple `#A932FF`
- Add new variables: `--accent-yellow`, `--accent-blue-light`, `--accent-green-light`, `--accent-purple-light`, `--accent-red-light`, `--accent-yellow-light` (both modes)
- Update all `--ring`, `--sidebar-primary`, `--sidebar-ring` to match new primary
- Update app-specific vars: `--accent-blue`, `--accent-green`, `--accent-purple`, `--accent-blue-bg` etc.
3. **Update `theme.json`**:
- `headings.h2.fontSize`: 27 → 24
- `editor.paddingHorizontal`: 40 → 64
- `editor.paddingVertical`: 20 → 32
4. **Install Phosphor Icons**: `pnpm add @phosphor-icons/react`
### Phase 2: Sidebar Restructure
**Scope**: Sidebar layout, navigation, icons
**Files**: `src/components/Sidebar.tsx`
**Estimated effort**: 1 Claude Code session
1. **Header**: Replace theme toggle with search icon (Phosphor `MagnifyingGlass`) + gear icon. Move theme toggle to status bar settings or a menu.
2. **Add search input** below header: Phosphor magnifying-glass prefix, 13px, bg `--secondary`, border-radius 6px
3. **Top nav**: Reduce to "All Notes" (Phosphor `FileText` 16px) and "Favorites" (Phosphor `Star` 16px), each with count badge pill
4. **Section groups**: Restructure to new pattern with:
- Consistent chevron + Phosphor icon (18px, bold) + label (13px/600) + count badge
- Icons: `FolderOpen` (Projects), `Flask` (Experiments), `Target` (Responsibilities), `ArrowsClockwise` (Procedures), `Users` (People), `CalendarBlank` (Events), `Tag` (Topics)
- Move People and Events from filters to section groups
- Remove "Untagged", "Changes", "Trash" from nav
5. **Commit button**: Update styling to match design (padding, badge style)
6. **Remove** People/Events/Changes/Trash/Untagged filter items
### Phase 3: NoteList Updates
**Scope**: Header, type pills, note item styling
**Files**: `src/components/NoteList.tsx`
**Estimated effort**: 1 Claude Code session
1. **Header**: Replace badge + create button with search icon (Phosphor `MagnifyingGlass`) + plus icon (Phosphor `Plus`), gap 12px
2. **Remove or hide** the always-visible search input — add toggle behavior on search icon click
3. **Type pills**: Switch to IBM Plex Mono, ALL CAPS format ("ALL 24", "PROJECTS 4"), update active/inactive styles per design
4. **Selected note**: Update to `bg: #2383E212`, left accent 3px `#2383E2` (update to new primary), remove type badge and status text from items
5. **Note items**: Adjust padding to 10px 16px, snippet line-height 1.5, remove type/status badges from individual items
### Phase 4: Editor — Tab Bar & Info Bar
**Scope**: New sub-components within Editor
**Files**: `src/components/Editor.tsx`, `src/components/Editor.css`
**Estimated effort**: 1 Claude Code session
1. **Tab Bar** (top of editor):
- 45px, bg `--sidebar`, border-bottom
- Active tab: bg `--background`, border-right, 12px/500 text, X close button
- Inactive tab: muted text, hidden X (show on hover)
- Right controls: Plus + Split (disabled) + Expand (disabled) — Phosphor icons
2. **Info Bar** (below tab bar):
- 45px, bg `--background`, border-bottom
- Left: breadcrumb `Type Title · N words · M` (M in accent-yellow when modified)
- Right: icon buttons (magnifying-glass functional, git-branch/cursor-text/sparkle/dots-three as **disabled placeholders** with `opacity: 0.4, cursor: not-allowed`)
3. **Adjust editor content padding** to 32px 64px per design
### Phase 5: Status Bar + Inspector Polish
**Scope**: New StatusBar component, Inspector refinements
**Files**: New `src/components/StatusBar.tsx`, `App.tsx`, `App.css`, `src/components/Inspector.tsx`
**Estimated effort**: 1 Claude Code session
1. **StatusBar.tsx** (new component):
- 30px fixed at bottom, bg `--sidebar`, border-top 1px
- Left: version + branch + sync status
- Right: AI model + notes count + bell (disabled placeholder) + settings (disabled placeholder)
- All Lucide icons 13-14px
2. **App.tsx / App.css**: Add StatusBar below main content, wrap layout in vertical flex (main panels + status bar)
3. **Inspector refinements**:
- Header: Phosphor `SlidersHorizontal` icon + "Properties" label + Phosphor `X` close
- Status badge: use `--accent-*-light` bg colors with `--accent-*` text
- "+ Add property" and "+ Link existing" buttons: match border/radius/padding from design
- History items: left-border 2px timeline style, 10px date text
### Phase 6: Icon Migration & Cleanup
**Scope**: Replace Lucide icons with Phosphor where specified
**Files**: All components
**Estimated effort**: 1 Claude Code session
1. **Audit all icon usage** across components
2. **Replace with Phosphor** where the design specifies (sidebar nav, section icons, NoteList header, editor toolbar icons, inspector)
3. **Keep Lucide** for: chevrons, X/close, tab close, status bar icons, git-commit-horizontal
4. **Remove unused Lucide imports**
5. **Visual verification**: Run `pnpm dev` and compare with `ui-design-screenshot.png`
---
## New Features as Disabled Placeholders
These buttons/icons appear in the design but don't have backend functionality yet. Add them as disabled UI elements:
| Element | Location | Icon | Notes |
|---|---|---|---|
| Split view | Tab bar controls | Phosphor `Columns` | `opacity: 0.4, cursor: not-allowed, title="Coming soon"` |
| Expand/focus | Tab bar controls | Phosphor `ArrowsOutSimple` | Same |
| Git branch viewer | Info bar right | Phosphor `GitBranch` | Same |
| Focus mode | Info bar right | Phosphor `CursorText` | Same |
| AI assist | Info bar right | Phosphor `Sparkle` | Same |
| More options | Info bar right | Phosphor `DotsThree` | Same |
| Bell/notifications | Status bar right | Lucide `Bell` | Same |
| Settings | Status bar right | Lucide `Settings` | Same |
| Gear/settings | Sidebar header | Phosphor `Gear` | Same |
---
## Files Inventory
| File | Changes |
|---|---|
| `src/index.css` | Color variables, font import |
| `src/theme.json` | H2 size, editor padding |
| `index.html` | IBM Plex Mono font link (if using CDN) |
| `package.json` | Add `@phosphor-icons/react` |
| `src/App.tsx` | Add StatusBar, adjust layout |
| `src/App.css` | Vertical flex for status bar |
| `src/components/Sidebar.tsx` | Major restructure |
| `src/components/NoteList.tsx` | Header, pills, item styling |
| `src/components/Editor.tsx` | Add TabBar, InfoBar sections |
| `src/components/Editor.css` | Tab/info bar styles |
| `src/components/Inspector.tsx` | Header, badges, history styling |
| `src/components/StatusBar.tsx` | **NEW** |

View File

@@ -1,130 +0,0 @@
# SF Symbols Migration Plan
> Current state: All icons use either **Phosphor Icons** (`@phosphor-icons/react`) or **Lucide React** (`lucide-react`). This document maps every icon to its SF Symbol equivalent for future migration.
---
## Icon Audit Summary (Phase 6 — 2026-02-17)
| Category | Count | Files | Status |
|---|---|---|---|
| Phosphor icons | 22 | `Sidebar.tsx`, `Editor.tsx`, `NoteList.tsx`, `Inspector.tsx` | All used, migrate to SF Symbols |
| Phosphor types | 1 (`IconProps`) | `Sidebar.tsx` | Type only — replace when migrating |
| Lucide (app components) | 4 | `Sidebar.tsx`, `Editor.tsx` | Evaluate per-icon |
| Lucide (StatusBar) | 7 | `StatusBar.tsx` | Keep Lucide per design |
| Lucide (shadcn/ui) | 7 | `ui/select.tsx`, `ui/dropdown-menu.tsx`, `ui/dialog.tsx` | Keep Lucide — library internals |
| **Total icon imports** | **41** | **8 files** | **0 unused** |
**Unused imports found**: None. All icon imports are actively used in JSX.
---
## Phosphor Icons — Current Usage
These are the primary UI icons introduced during the redesign. All should migrate to SF Symbols.
| Phosphor Icon | SF Symbol Equivalent | File(s) | Usage |
|---|---|---|---|
| `MagnifyingGlass` | `magnifyingglass` | `Sidebar.tsx`, `NoteList.tsx`, `Editor.tsx` | Search icon in sidebar header, note list header, editor info bar |
| `Gear` | `gearshape` | `Sidebar.tsx` | Settings icon in sidebar header (disabled placeholder) |
| `FileText` | `doc.text` | `Sidebar.tsx` | "All Notes" nav item icon |
| `Star` | `star.fill` | `Sidebar.tsx` | "Favorites" nav item icon |
| `FolderOpen` | `folder` | `Sidebar.tsx` | "Projects" section group icon |
| `Flask` | `flask` | `Sidebar.tsx` | "Experiments" section group icon |
| `Target` | `target` | `Sidebar.tsx` | "Responsibilities" section group icon |
| `ArrowsClockwise` | `arrow.clockwise` | `Sidebar.tsx` | "Procedures" section group icon |
| `Users` | `person.2` | `Sidebar.tsx` | "People" section group icon |
| `CalendarBlank` | `calendar` | `Sidebar.tsx` | "Events" section group icon |
| `Tag` | `tag` | `Sidebar.tsx` | "Topics" section group icon |
| `TagSimple` | `tag` | `Sidebar.tsx` | "Untagged" nav item icon |
| `Trash` | `trash` | `Sidebar.tsx` | "Trash" nav item icon |
| `Plus` | `plus` | `NoteList.tsx`, `Editor.tsx` | Create note button, new tab button |
| `Columns` | `rectangle.split.2x1` | `Editor.tsx` | Split view button (disabled placeholder) |
| `ArrowsOutSimple` | `arrow.up.left.and.arrow.down.right` | `Editor.tsx` | Expand/focus button (disabled placeholder) |
| `GitBranch` | `arrow.triangle.branch` | `Editor.tsx` | Version history button (disabled placeholder) |
| `CursorText` | `character.cursor.ibeam` | `Editor.tsx` | Focus mode button (disabled placeholder) |
| `Sparkle` | `sparkles` | `Editor.tsx` | AI assist button (disabled placeholder) |
| `DotsThree` | `ellipsis` | `Editor.tsx` | More options button (disabled placeholder) |
| `SlidersHorizontal` | `slider.horizontal.3` | `Inspector.tsx` | Inspector header icon |
| `X` (Phosphor) | `xmark` | `Inspector.tsx` | Inspector close button |
| `IconProps` (type) | n/a | `Sidebar.tsx` | TypeScript type for icon component props |
---
## Lucide React — Current Usage
### App Components
These Lucide icons are used in custom app components. Some may migrate to SF Symbols; others are kept for specific reasons.
| Lucide Icon | SF Symbol Equivalent | File | Usage | Migration Notes |
|---|---|---|---|---|
| `ChevronRight` | `chevron.right` | `Sidebar.tsx` | Section group expand chevron | Keep Lucide or migrate — small utility icon |
| `ChevronDown` | `chevron.down` | `Sidebar.tsx` | Section group collapse chevron | Keep Lucide or migrate — small utility icon |
| `GitCommitHorizontal` | `circle.dotted` | `Sidebar.tsx` | Commit & Push button icon | Keep Lucide or migrate |
| `X` (Lucide) | `xmark` | `Editor.tsx` | Tab close button | Keep Lucide or migrate |
| `Package` | `shippingbox` | `StatusBar.tsx` | App version indicator | Keep Lucide — status bar uses Lucide per design |
| `GitBranch` (Lucide) | `arrow.triangle.branch` | `StatusBar.tsx` | Git branch indicator | Keep Lucide — status bar uses Lucide per design |
| `RefreshCw` | `arrow.clockwise` | `StatusBar.tsx` | Sync status indicator | Keep Lucide — status bar uses Lucide per design |
| `Sparkles` (Lucide) | `sparkles` | `StatusBar.tsx` | AI model indicator | Keep Lucide — status bar uses Lucide per design |
| `FileText` (Lucide) | `doc.text` | `StatusBar.tsx` | Notes count indicator | Keep Lucide — status bar uses Lucide per design |
| `Bell` | `bell` | `StatusBar.tsx` | Notifications (disabled placeholder) | Keep Lucide — status bar uses Lucide per design |
| `Settings` | `gearshape` | `StatusBar.tsx` | Settings (disabled placeholder) | Keep Lucide — status bar uses Lucide per design |
### shadcn/ui Components (Keep Lucide)
These are standard shadcn/ui library components that use Lucide as their built-in icon system. These should **not** be migrated — they are part of the component library's internal implementation.
| Lucide Icon | File | Usage |
|---|---|---|
| `CheckIcon` | `ui/select.tsx` | Selected item indicator |
| `ChevronDownIcon` | `ui/select.tsx` | Select trigger arrow, scroll-down button |
| `ChevronUpIcon` | `ui/select.tsx` | Scroll-up button |
| `CheckIcon` | `ui/dropdown-menu.tsx` | Checkbox item indicator |
| `ChevronRightIcon` | `ui/dropdown-menu.tsx` | Sub-menu trigger arrow |
| `CircleIcon` | `ui/dropdown-menu.tsx` | Radio item indicator |
| `XIcon` | `ui/dialog.tsx` | Dialog close button |
---
## Approach Options for SF Symbols in React/Tauri
### Option 1: `sf-symbols-react` npm package
- **Pros**: Drop-in React components, familiar API (`<SFSymbol name="magnifyingglass" />`)
- **Cons**: Third-party package, may lag behind Apple's symbol updates, limited weight/rendering options
- **Status**: Check npm for current maintenance state before adopting
### Option 2: SVG extraction from SF Symbols app
- **Pros**: Exact Apple-quality vectors, no runtime dependency, full control over styling
- **Cons**: Manual export process per icon, potential licensing concerns (SF Symbols license restricts use to Apple platforms), need to manage SVG sprite or individual files
- **How**: Export SVGs from the SF Symbols macOS app, create a `src/icons/` directory with individual SVG components or a sprite sheet
### Option 3: Apple's SF Symbols font (native approach via Tauri)
- **Pros**: Pixel-perfect on macOS, automatic weight matching, system-native feel
- **Cons**: Only works on macOS (not cross-platform), requires Tauri native font access, won't render in browser dev mode
- **How**: Use CSS `font-family: "SF Pro"` with Unicode code points, or invoke native APIs from Tauri's Rust backend
### Option 4: Hybrid — SVG in browser, native in Tauri
- **Pros**: Best of both worlds — browser dev mode uses SVGs, production Tauri build uses native SF Symbols
- **Cons**: More complex build setup, need to maintain two icon systems
- **How**: Build an `<Icon>` wrapper component that checks `window.__TAURI__` and renders native or SVG accordingly
### Recommendation
**Option 2 (SVG extraction)** is the most practical starting point:
- Laputa is a macOS-only Tauri app, so SF Symbols licensing applies (Apple platform)
- SVGs work in both browser dev mode and Tauri production
- No third-party dependency to maintain
- Can later upgrade to Option 4 (hybrid native) for perfect macOS integration
---
## Migration Steps (Future)
1. Export all needed SF Symbol SVGs from the SF Symbols macOS app
2. Create `src/icons/sf-symbols/` with a React component per icon (or a single sprite)
3. Build a thin `<SFIcon name="..." size={} />` wrapper for consistent API
4. Replace Phosphor imports file-by-file (Sidebar → NoteList → Editor → Inspector)
5. Decide whether to also replace Lucide in StatusBar and utility icons (chevrons, X)
6. Keep Lucide in shadcn/ui components — do not modify those
7. Once all Phosphor icons are replaced, remove `@phosphor-icons/react` from dependencies
8. Run `pnpm build` and visually verify all icons render correctly

View File

@@ -1,53 +0,0 @@
# Laputa — Vision
Laputa is a personal knowledge base where humans and AI agents collaborate as equals.
---
## Core principles
### 1. The vault is the source of truth
Everything lives in the vault as plain text files. Notes, relations, configuration, instructions — all Markdown with YAML frontmatter. No proprietary database, no hidden state. If you can open a terminal, you can read your vault. If you can write Markdown, you can modify it.
### 2. Vault-native configuration
Laputa configures itself through files inside the vault — the same files you write and read every day. There is no separate "settings app" or admin panel. If you want to change a theme, you edit a note. If you want to give instructions to an AI agent, you write a note. If you want to define a template, you create a note.
This applies to:
- **`_themes/`** — themes as notes with a YAML block in the body. Edit `_themes/dark.md`, see the colors change in real time.
- **`AGENTS.md`** — instructions for AI agents. Write what you want them to know about your vault in plain language. They read it before acting.
- **`_templates/`** — note templates per type. Create `_templates/event.md` and every new event starts from that structure.
- **`_procedures/`** — recurring tasks as notes with a `schedule` frontmatter field.
The principle: **if it can be expressed in frontmatter + Markdown, it doesn't need a UI**.
### 3. Structure through types, not folders
Notes have a `type` field. Types determine folders, icons, and colors — but the structure is defined by the data, not the filesystem hierarchy. You can query "all events in February" without knowing anything about folder layout.
Relations between notes are expressed as frontmatter arrays: `people: [Marco, Sara]`. A wikilink `[[Marco]]` in the body navigates to the person note. The graph emerges from the data, not from a separate graph database.
### 4. The file is the interface
You can use Laputa's UI, or you can open a terminal. Or a text editor. Or Claude Code. They all operate on the same files. There is no difference between "the app" and "the vault" — the vault is the app.
This is why Laputa has an MCP server: external agents get the same tools the in-app AI panel uses. The interface is a convenience, not a requirement.
### 5. Humans and AI as collaborators
Pulse — the activity feed — shows the history of the vault without distinguishing between human commits and agent commits. That's intentional. Laputa is designed to be a space where you and your AI agents work together, each contributing to the same knowledge base.
The AI doesn't have a separate workspace. It works in yours.
---
## What Laputa is not
- Not a todo app (though you can use it as one)
- Not a note-taking app that syncs to the cloud (the vault is yours, sync however you want — git, iCloud, rsync)
- Not a replacement for a terminal (power users will use both)
- Not trying to abstract away git (git is a feature, not an implementation detail)
---
## The long game
A vault that grows with you for years. Events, people, projects, thoughts — all interconnected, all version-controlled, all accessible to any tool that can read a file.
Ten years from now, your vault should still be readable. Plain text is forever.

View File

@@ -1,41 +0,0 @@
#!/usr/bin/env python3
"""Analyze which broken links are to existing vs non-existent notes."""
import sys
sys.path.insert(0, '/Users/luca/Workspace/laputa-app')
from select_demo_notes import build_graph, select_notes, LAPUTA_ROOT
nodes, link_lookup = build_graph()
selected = select_notes(nodes, link_lookup, target_count=1000)
print("\n🔍 Analyzing broken links...")
# Count links by type
total_outlinks = 0
resolved = 0
unresolved_but_exists = 0
unresolved_not_exists = 0
for path in selected:
node = nodes[path]
for link_ref in node['outlinks']:
total_outlinks += 1
if link_ref in link_lookup:
target = link_lookup[link_ref]
if target in selected:
resolved += 1
else:
# Exists but not in selection
unresolved_but_exists += 1
else:
# Doesn't exist at all
unresolved_not_exists += 1
print(f"Total outlinks: {total_outlinks}")
print(f" Resolved (in selection): {resolved} ({resolved/total_outlinks*100:.1f}%)")
print(f" Unresolved but note exists: {unresolved_but_exists} ({unresolved_but_exists/total_outlinks*100:.1f}%)")
print(f" Unresolved - note doesn't exist: {unresolved_not_exists} ({unresolved_not_exists/total_outlinks*100:.1f}%)")
print(f"\nIf we include ALL existing notes (not just selected):")
print(f" Max possible resolution: {(resolved + unresolved_but_exists)/total_outlinks*100:.1f}%")

View File

@@ -0,0 +1,7 @@
---
Is A: Area
Status: Active
---
# Refactoring
Area note covering refactoring practices, principles, and techniques for improving code quality without changing external behavior.

View File

@@ -0,0 +1,6 @@
---
Is A: Note
---
# Refactoring Ideas
Collection of ideas for refactoring the codebase to improve maintainability and performance.

View File

@@ -0,0 +1,6 @@
---
Is A: Note
---
# Refactoring Key Ideas
Key takeaways from Martin Fowler's Refactoring book and other refactoring resources.

View File

@@ -0,0 +1,6 @@
---
Is A: Note
---
# Refactoring Patterns
Common refactoring patterns including Extract Method, Rename Variable, and Replace Conditional with Polymorphism.

View File

@@ -2,6 +2,30 @@
Key abstractions and domain models in Laputa.
## Design Philosophy
Laputa's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
The full set of design principles is documented in [ARCHITECTURE.md](./ARCHITECTURE.md#design-principles).
## Semantic Field Names (conventions)
These frontmatter field names have special meaning in Laputa's UI:
| Field | Meaning | UI behavior |
|---|---|---|
| `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping |
| `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header |
| `url:` | External link | Clickable link chip in editor header |
| `date:` | Single date | Formatted date badge |
| `start_date:` + `end_date:` | Duration/timespan | Date range badge |
| `goal:` + `result:` | Progress | Progress indicator in editor header |
| `Workspace:` | Vault context filter | Global workspace filter |
| `Belongs to:` | Parent relationship | Relationship chip in Properties panel |
| `Related to:` | Lateral relationship | Relationship chip in Properties panel |
The list of default-shown relationships and semantic property rendering rules can be customized via `config/relations.md` and `config/semantic-properties.md` in the vault.
## Document Model
All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth.
@@ -16,7 +40,7 @@ interface VaultEntry {
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.
isA: string | null // Entity type: Project, Procedure, Person, etc. (from frontmatter `type:` field)
aliases: string[] // Alternative names for wikilink resolution
belongsTo: string[] // Parent relationships (wikilinks)
relatedTo: string[] // Related entity links (wikilinks)
@@ -37,9 +61,11 @@ interface VaultEntry {
}
```
### Entity Types (isA)
### Entity Types (isA / type)
Entity type is inferred from the folder structure. The vault is organized by type:
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
Type is also inferred from the folder structure when `type:` is absent. The vault is organized by type:
```
~/Laputa/
@@ -66,7 +92,7 @@ Mapping logic lives in `vault/mod.rs:parse_md_file()`. If a folder doesn't match
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
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
- 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
@@ -171,7 +197,7 @@ type SidebarSelection =
- 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 (or explicit `Is A`/`type` frontmatter)
- Infers entity type from parent folder name (or explicit `type:` frontmatter; `Is A:` accepted as legacy alias)
- Parses dates as ISO 8601 to Unix timestamps
- Extracts relationships, outgoing links, custom properties, word count, snippet
5. Sorts by `modified_at` descending
@@ -181,12 +207,13 @@ type SidebarSelection =
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
1. Reads `.laputa-cache.json` if it exists
1. Reads cache from `~/.laputa/cache/<vault-hash>.json` (external to vault)
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
6. Writes updated cache atomically (write to `.tmp`, then rename)
7. On first run, migrates any legacy `.laputa-cache.json` from inside the vault
### Frontmatter Manipulation (Rust)
@@ -334,11 +361,11 @@ Two-layer theming:
### Vault-Based Themes
Themes are markdown notes in `theme/` with `Is A: Theme` frontmatter. Each property becomes a CSS variable with `--` prefix.
Themes are markdown notes in `theme/` with `type: Theme` frontmatter. Each property becomes a CSS variable with `--` prefix.
```yaml
---
Is A: Theme
type: Theme
Description: Light theme with warm, paper-like tones
background: "#FFFFFF"
foreground: "#37352F"
@@ -381,7 +408,7 @@ 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:
- **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.
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, and all custom relationship fields as clickable wikilink chips.

View File

@@ -2,6 +2,51 @@
Laputa is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
## Design Principles
### Filesystem as the single source of truth
The vault is a folder of plain markdown files. The app never owns the data — it only reads and writes files. The cache, React state, and any in-memory representation are always derived from the filesystem and must be reconstructible by deleting them. When in doubt, the file on disk wins.
### Convention over configuration
Laputa is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `Belongs to:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
This principle directly serves AI-readability: the more structure comes from shared conventions rather than per-user custom configurations, the easier it is for an AI agent to understand and navigate the vault correctly — without needing bespoke instructions for every setup.
### No hardcoded exceptions
No field names, folder paths, or vault-specific values should be hardcoded in the application source code. What can be a convention should be a convention. What needs to be configurable should live in a file. Relationship fields are detected dynamically by checking whether values contain `[[wikilinks]]` — no hardcoded field name lists.
### AI-first knowledge graph
Notes are not just documents — they are nodes in a structured graph of people, projects, events, responsibilities, and ideas. Every design decision should ask: "Does this make the knowledge graph easier for a human *and* an AI to navigate?" Conventions that are legible to both are better than conventions that are legible only to one.
### Three representations, one authority
Vault data exists in three forms simultaneously:
1. **Filesystem** — the `.md` files on disk. This is the single source of truth.
2. **Cache**`~/.laputa/cache/<hash>.json`, an index for fast startup. Always reconstructible from the filesystem.
3. **React state** — the in-memory `VaultEntry[]` during a session. Always derived from the cache or filesystem.
These must never diverge permanently. If they do, the filesystem wins and the cache/state are rebuilt.
#### Ownership rules
| Layer | Owner | Writes to | Reads from |
|-------|-------|-----------|------------|
| Filesystem | Tauri Rust commands (`save_note_content`, `update_frontmatter`, etc.) | Disk | — |
| Cache | `scan_vault_cached()` in `vault/cache.rs` | `~/.laputa/cache/` | Filesystem + git diff |
| React state | `useVaultLoader` + `useEntryActions` + `useNoteActions` | In-memory `entries` | Cache (on load), filesystem (on reload) |
#### Invariants
1. **Disk-first writes**: All functions that change vault data must write to disk (via Tauri IPC) *before* updating React state. This ensures that if the disk write fails, React state remains consistent with what's actually on disk.
2. **Optimistic UI with rollback**: Where responsiveness matters (e.g. `persistOptimistic` in `useNoteActions`), state may update before disk confirmation — but a failure callback must revert the optimistic state.
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
## Tech Stack
| Layer | Technology | Version |
@@ -50,7 +95,7 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
│ Tauri IPC│ Vite Proxy / WS │
│ ┌──────────────▼────┐ ┌──▼────────────────────────────────┐ │
│ │ Rust Backend │ │ External Services │ │
│ │ lib.rs → 61 cmds │ │ Anthropic API (Claude chat) │ │
│ │ lib.rs → 62 cmds │ │ Anthropic API (Claude chat) │ │
│ │ vault/ │ │ Claude CLI (agent subprocess) │ │
│ │ frontmatter/ │ │ MCP Server (ws://9710, 9711) │ │
│ │ git/ │ │ qmd (search/indexing engine) │ │
@@ -89,7 +134,7 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
- **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.
- **Inspector / AI Chat / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, 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. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize.
@@ -176,7 +221,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|------|--------|-------------|
| `open_note` | `path` | Open and read a note by relative path |
| `read_note` | `path` | Read note content (alias for `open_note`) |
| `create_note` | `path, title, [is_a]` | Create new note with title and optional type frontmatter |
| `create_note` | `path, title, [type]` | Create new note with title and optional type frontmatter |
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
| `append_to_note` | `path, text` | Append text to end of existing note |
| `edit_note_frontmatter` | `path, patch` | Merge key-value patch into YAML frontmatter |
@@ -300,7 +345,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
### 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).
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan). Writes are atomic (write to `.tmp` then rename). Legacy `.laputa-cache.json` files inside the vault are auto-migrated and deleted on first run.
### Three Cache Strategies
@@ -308,8 +353,6 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
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.
@@ -321,7 +364,7 @@ See [THEMING.md](./THEMING.md) for the full theme system documentation.
### 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).
Themes are markdown notes in the `theme/` folder with `type: Theme` frontmatter (`Is A: Theme` accepted as legacy alias). 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
@@ -471,13 +514,13 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `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 |
| `commands.rs` | All 62 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 |
## Tauri IPC Commands (61 total)
## Tauri IPC Commands (62 total)
### Vault Operations
@@ -491,6 +534,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `batch_archive_notes` | Archive multiple notes |
| `batch_trash_notes` | Trash multiple notes |
| `purge_trash` | Delete notes trashed >30 days ago |
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
| `create_getting_started_vault` | Bootstrap demo vault |

View File

@@ -332,7 +332,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
### Add a new entity type
1. Create the folder in the vault (e.g., `~/Laputa/mytype/`)
2. Create a type document: `type/mytype.md` with `Is A: Type` frontmatter (icon, color, order, etc.)
2. Create a type document: `type/mytype.md` with `type: 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
@@ -344,7 +344,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
### Add or modify a theme
1. **Vault-based** (preferred): Create/edit a markdown note in `theme/` with `Is A: Theme` frontmatter
1. **Vault-based** (preferred): Create/edit a markdown note in `theme/` with `type: Theme` frontmatter
2. **Programmatic**: Edit defaults in `src-tauri/src/theme/defaults.rs`
3. See `docs/THEMING.md` for the full property reference

View File

@@ -263,7 +263,7 @@ M1 passed all tests but showed 0 notes — vault path wrong, error silently swal
- [x] Tauri v2 + React 19 + TypeScript + Vite 7 project setup
- [x] Configure Vitest (7 tests), Playwright (2 E2E tests), Rust tests (10 tests)
- [x] Rust backend: `list_vault` command — scans directory, parses YAML frontmatter via `gray_matter` crate
- [x] Rust backend: extracts Is A, aliases, Belongs to, Related to, Status, Owner, Cadence, title from H1
- [x] Rust backend: extracts type (Is A), aliases, Belongs to, Related to, Status, Owner, Cadence, title from H1
- [x] React: four-panel layout (Sidebar 250px, NoteList 300px, Editor flex, Inspector 280px), all resizable
- [x] Tauri mock layer for browser testing (`src/mock-tauri.ts`)
- [x] Screenshot verification via Playwright (`e2e/screenshot.spec.ts`)
@@ -281,7 +281,7 @@ bc75647 Remove unused Vite scaffold files
### M2: Sidebar & Note List
**Goal:** Navigate the vault via sidebar, see filtered note lists.
- [ ] Sidebar: Filters section (All Notes, Favorites, Trash)
- [ ] Sidebar: Section Groups (Projects, Experiments, Responsibilities, Procedures) — populated from frontmatter `Is A`
- [ ] Sidebar: Section Groups (Projects, Experiments, Responsibilities, Procedures) — populated from frontmatter `type:`
- [ ] Sidebar: Topics — flat list, populated from `Related to` topic links
- [ ] Note list: show title, preview snippet, date, type indicator
- [ ] Note list: sort by last modified (descending)
@@ -313,7 +313,7 @@ bc75647 Remove unused Vite scaffold files
### M5: File Operations & Polish
**Goal:** Create, rename, delete files. Polish for daily-driver use.
- [ ] Create new note (with type selector → sets `Is A` and target folder)
- [ ] Create new note (with type selector → sets `type:` and target folder)
- [ ] Rename file (updates filename + title)
- [ ] Delete → move to trash
- [ ] Keyboard shortcuts (Cmd+N new, Cmd+S save, Cmd+P quick open/search)

108
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,108 @@
# Laputa — Product Roadmap
*Strategic directions, not implementation tasks. Each item here represents a direction that will be broken down into many smaller tasks when the time comes.*
*Updated: March 2026.*
---
## Consolidation sprint (current priority)
Before building new features, the architectural foundations must be solid. Key structural fixes underway:
- Move vault cache outside the vault directory (→ `~/.laputa/cache/`) with atomic writes
- Flip `type:` to canonical field in Rust parser (`Is A:` becomes alias)
- Remove `allContent` from the architecture — derive backlinks from open tabs only
- ~~Remove hardcoded `RELATIONSHIP_KEYS` — detect wikilink fields dynamically~~ ✅ Done
- Fix hardcoded vault path in `resolveNewNote` / `resolveNewType` / `resolveDailyNote`
- Define and enforce the three-source-of-truth contract (filesystem → cache → React state)
These are not features — they are the foundation everything else is built on.
---
## Strategic directions
### 1. Semantic properties
**What:** Conventional frontmatter field names (`status:`, `url:`, `start_date:`, `end_date:`, `goal:`, `result:`) trigger rich UI rendering beyond the Properties panel — chips in the note list, progress indicators in the editor header, date range badges.
**Why:** Notes are not just documents. A Project has a start and end. A Responsibility has KPIs. A Procedure has an owner and a cadence. The app should surface this structure visually, not just store it as plain text.
**Convention over configuration:** the rendering rules ship as sensible defaults. Users can override via `config/semantic-properties.md` in the vault — a plain markdown file, editable from within the app.
**Draft tasks:** created in Todoist. To be prioritized after consolidation sprint.
---
### 2. Default relationships in Properties panel
**What:** The Properties panel shows a set of relationship fields by default — even when empty — guiding the user toward a connected knowledge graph. Defaults include: Belongs to, Related to, Events, People (Type is already shown).
**Why:** A new note starts with a completely empty Properties panel today. There's no guidance on how to connect it. Laputa is opinionated — it should show you the connections that matter.
**Convention over configuration:** the default list is built in, but can be overridden via `config/relations.md` in the vault.
**Draft tasks:** created in Todoist. Needs design discussion (per-type overrides?) before implementation.
---
### 3. Global workspace filter
**What:** A top-level workspace switcher (below the traffic lights) that filters the entire app — sidebar, note list, search — to show only notes belonging to the selected workspace, plus shared notes (those without a Workspace field).
**Why:** A single vault often contains both personal and work content. A workspace filter lets you focus on one context at a time without cognitive overhead.
**How:** Notes opt into a workspace via `Workspace: [[workspace/refactoring]]` frontmatter. Workspace notes are auto-detected from the `workspace/` folder. No setup required.
**Future trajectory:** Workspaces are the seed of a multi-vault, multi-user access control model. In the future, workspaces may map to separate Git repositories — each with their own access permissions. Different people see different workspaces (vaults). Git provides the audit trail. This enables Laputa to grow from a personal tool to a small-team knowledge base without rebuilding the product.
**Draft tasks:** created in Todoist. Lower priority than semantic properties and default relationships.
---
### 4. Inbox and capture pipeline
**What:** An Inbox section that surfaces all unorganized notes — those with no outgoing relationships. Replaces "All Notes" as the primary landing section. Capture integrations (Chrome extension, iPhone share sheet, Readwise sync) feed into the inbox automatically.
**Why:** Capture and organize are fundamentally different activities and should be treated separately. Today Laputa has no concept of an unorganized note — everything lands in the same pool. The inbox makes the unorganized state visible and actionable, creating a discipline: Inbox Zero, reached weekly.
**The inbox as a smart filter:** not a folder. Any note without `Belongs to:`, `Related to:`, or other meaningful relationship is automatically in the inbox. Connecting a note to something removes it from the inbox, automatically.
**Capture integrations (future, each a separate feature):**
- Chrome extension → saves URL/clip as a note to the vault via Git
- iPhone share sheet → quick capture from any app
- Readwise / Kindle highlights → synced via Git automation
- Voice memo → transcribed and dropped into inbox
**Priority:** The Inbox UI is high-value and can be implemented without the capture integrations. Integrations come after.
---
### 5. Mobile apps
**What:** Native apps for iPhone and iPad — not ports of the desktop app, but purpose-built for each form factor.
**iPhone:** Optimized for fast capture. Quick note creation, voice memos, brief thoughts. The primary use case is getting something into the vault quickly while away from the desk. Minimal reading and editing.
**iPad:** A more capable mirror of the desktop experience — reading, editing, navigating the vault. Not a full four-panel layout, but enough to work on notes meaningfully. Think "laptop replacement for light work sessions."
**Why it matters:** Laputa's value as a personal knowledge system depends on being able to capture things wherever you are. Without mobile capture, important notes get lost or end up scattered in other apps.
**Sync:** Git-based, same as desktop. The vault is a Git repo — mobile apps commit and pull like any other client.
**Priority:** After the desktop experience is solid. Not before.
---
## Principles for this roadmap
- **Foundations before features** — a shaky architecture multiplies the cost of every feature built on top of it
- **Convention over configuration** — ship strong defaults, allow customization via vault files
- **File-first** — every strategic direction must be achievable without breaking the markdown-files-on-disk model
- **AI-readable by design** — conventions that humans find intuitive should also be legible to AI agents navigating the vault
---
*For active tasks and bugs, see the Todoist board (Laputa App project).*
*For architectural decisions and design principles, see [ARCHITECTURE.md](./ARCHITECTURE.md) and [VISION.md](./VISION.md).*

View File

@@ -1,156 +1,223 @@
# Laputa — Product Vision
*Written by Brian based on conversations with Luca Rossi, Feb 2026.*
*Written by Brian based on conversations with Luca Rossi, FebMar 2026.*
*This is a living document — update it as the vision evolves.*
---
## Why Laputa exists
## Why this, why now, why us
Luca has been doing personal knowledge management since university — long before it had a name. Over the years, through two startups, becoming a CTO, and eventually building Refactoring (a newsletter publishing 2-3 articles/week), note-taking evolved from a nice-to-have to a core part of his work. The ability to synthesize ideas, connect concepts across time, and turn accumulated knowledge into articles reliably every week — this only works with a well-structured system.
Before the what and how: the why.
For years, that system lived in Notion. But over time, the overlap between what Notion offers and what Luca actually needs started to shrink. Notion became simultaneously too narrow (missing specific things he needed) and too wide (full of flexibility he didn't want). The gap became impossible to ignore when AI entered the picture.
The best projects are built by people who have an unusually strong answer to "why are you the right person to build this?" This is that answer.
## The core insight: local files + Git = AI-native PKM
**Luca Rossi** is a startup founder and former generalist CTO — someone who can build a product end-to-end across code, design, scope, and product. And for the last five years, full-time, he has run Refactoring: a technical newsletter with nearly 200,000 subscribers, for which he has written over 300 original articles. In word count, that's roughly two *Lord of the Rings* novels.
The fundamental insight behind Laputa is architectural: **a knowledge base made of local Markdown files, version-controlled with Git, is orders of magnitude more AI-friendly than any SaaS-based system.**
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Laputa is an attempt to bottle that system.
Notion's AI struggles with complex workspaces — slow, inaccurate, often failing to understand its own structure. Meanwhile, an AI like Claude or Claude Code working on a local vault of Markdown files can read, edit, and reorganize thousands of documents in seconds, with full comprehension.
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Laputa is not theorized — it's been battle-tested for years at scale.
This isn't a feature — it's a structural advantage that no Notion redesign can fix. The architecture *is* the product.
**The distribution is built in.** Refactoring reaches ~200,000 engineers, managers, and technical leaders — exactly the people most receptive to a tool like this. The audience already trusts the author on this topic, because they've been reading his writing about knowledge management and learning for years.
Additional benefits that fall out of this choice for free:
- **Version control**: every change is tracked, diffable, reversible
- **Open format**: your knowledge is yours, readable by any tool, forever
- **Remote AI access**: an AI agent can commit to your Git repo from anywhere — your knowledge base becomes programmable
- **Zero lock-in**: if something better comes along, you leave. The trust between Laputa and the user is earned daily, not enforced by proprietary formats
This is not a product looking for a market. It's a tool built by its first power user, for an audience that already knows and trusts him.
## Why not just use Obsidian?
**Why Laputa, in the context of Refactoring.**
Refactoring is a newsletter about how software is built, how teams work, and how digital products are developed — written from Luca's experience and conversations with other tech leaders. A natural question follows: what is the author's own current experience building software with AI?
Laputa answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
This is why Laputa is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
The strategy is coherent: build the tool you describe, make the work visible, let the product speak for the author.
---
## The problem
Most people who want to work effectively with AI face a version of the same problem: **they don't have their knowledge organized in a way that AI can actually use.**
They have notes scattered across Notion, Apple Notes, browser bookmarks, and email. Some of it is structured, most of it isn't. Even the people who do maintain a knowledge base discover that AI tools — ChatGPT, Notion AI, others — struggle to navigate it meaningfully. The knowledge is there, but it's not *accessible*.
The problem has two distinct layers:
1. **Architectural**: most knowledge tools store data in proprietary formats on remote servers. AI tools can't read them efficiently, can't commit changes back, can't reason over the full structure. The format itself creates a ceiling.
2. **Methodological**: even with the right tool, most people don't know *how* to organize knowledge so it becomes useful over time — what to capture, how to connect things, how to turn raw notes into a system that works with you instead of against you.
Laputa addresses both layers, together. That's what makes it different.
---
## The insight: tool and method, together
Most PKM tools give you a blank canvas and leave the rest to you. They solve the first problem (somewhere to put things) but not the second (how to organize them). The result is that sophisticated users build complex custom systems, while everyone else gives up.
Laputa's position is different: **we ship the method alongside the tool.**
The method is opinionated but not rigid. It tells you: here's how to think about your work, here's where different kinds of notes belong, here's how to connect them. If it fits your needs — great, start immediately. If your situation is different — customize it. The types, the relationships, the structure can all be changed. But you don't have to figure it out from scratch.
This combination — an opinionated method on top of a technically excellent foundation — is what makes Laputa genuinely useful to people who are stuck, not just people who already know what they're doing.
---
## The method: a framework for knowledge work
### The knowledge ontology
Laputa organizes work around two axes:
| | **One-time** | **Recurring** |
|---|---|---|
| **Multi-session** | **Project** (has a start and end) | **Responsibility** (no end, measured by KPIs) |
| **Single-session** | *Task* (lives in a task manager) | **Procedure** (checklist, routine) |
Everything else is context:
- **Notes** — the atomic unit. Any note connects to one or more of the above.
- **Topics** — areas of interest with no performance expectation. A knowledge repository.
- **Events** — things that happened, anchored to a date.
- **People** — contacts and their history.
Relations between notes are first-class citizens — not just wiki-links, but typed, bidirectional connections that make the knowledge graph navigable.
This ontology is not arbitrary. It maps cleanly to how both individuals and organizations actually structure their work: companies have projects, responsibilities, procedures, and people. So do independent creators. So do individuals managing their lives.
### Knowledge has a purpose
A principle that underlies everything in Laputa: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
This is the difference between a knowledge system that works over years and one that collapses after a few weeks. Without a real purpose, the maintenance cost of taking notes is never justified, and people stop. With a purpose — writing regularly, building things, making decisions — the system pays for itself.
What you *do* with organized knowledge depends on who you are:
- **Writers and content creators** — the output is articles, essays, posts. Captures become highlights, highlights become **evergreen notes** (small, atomic, timeless ideas), evergreen notes become building blocks for articles. Evergreen notes are a middle layer: not the raw input, not the final output, but the refined reusable units that make writing easier and faster.
- **Builders and project-driven people** — the output is shipped work. Captures feed projects, decisions, and procedures. Evergreen notes matter less; the project knowledge graph matters more.
- **Operators and managers** — the output is better systems and decisions. Captures feed responsibilities (KPIs, workflows) and procedures (how we do things). The value accumulates in the recurring structure, not in individual notes.
The framework is flexible enough to fit all three — and most people are a mix. What stays constant is the flow: **capture → organize → express**. The *what* of expressing changes; the discipline doesn't.
### The two-phase workflow: capture and organize
Notes move through two distinct phases, and the transition between them is intentional.
**Capture** — fast, frictionless, available everywhere. A thought, a saved article, a Kindle highlight, a voice memo. The cardinal rule: never let friction during capture cause a good idea to be lost. Captured notes land in the vault unconnected — no relationships, no organization. That's fine. That's the point.
**Organize** — a deliberate, periodic activity (weekly is the natural cadence). You ask: *what is this useful for?* Many things that seemed important when captured won't survive this question — deleting >50% of captures is normal and healthy. For the things that survive: connect them. Link to a Project, a Responsibility, a Topic. Every note should eventually connect to at least one actionable container. If you can't connect something to anything, that's a signal worth paying attention to.
**The Inbox** is the UI expression of this split: a smart section that shows all unorganized notes — those with no outgoing relationships. The goal is Inbox Zero, reached periodically (weekly). The inbox is not a folder; it's a derived state. Connecting a note to something removes it automatically.
### Convention over configuration
The method lives in the app as *conventions*: standard field names and folder structures that have well-defined meanings and trigger specific behavior.
`status:` shows a colored chip. `Workspace: [[workspace/refactoring]]` assigns a note to a context. `Belongs to:` connects it to its parent. `start_date:` and `end_date:` show a duration badge. The app recognizes these by convention, without any setup.
Users who want more can override the defaults: `config/relations.md` changes which relationship fields appear by default; `config/semantic-properties.md` controls how fields are rendered. But the defaults work immediately, for everyone.
This is convention *over* configuration — not convention *instead of* it.
---
## The foundation: architecture that earns trust
The method is only as good as the system it runs on. Laputa's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
### Local files, version-controlled with Git
Every note is a plain Markdown file on your disk. There is no database, no proprietary format, no sync lock-in. The files are readable by any tool that can open a text file — today and in twenty years.
Git provides version control: every change is tracked, diffable, reversible. You have a full audit trail of what changed, when, and why. Collaboration happens via Git — the same way software teams have collaborated for decades, without any proprietary cloud in between.
### AI-native by design
A vault of plain Markdown files, version-controlled with Git, is dramatically more AI-friendly than any SaaS-based system.
An AI agent working on a local vault can read thousands of notes in seconds, understand their structure, write new ones, connect existing ones, and commit the changes back — all with full comprehension. Notion's AI can't do this. No SaaS-based AI can do this, because the architecture doesn't allow it.
More importantly: the more a vault follows Laputa's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
### Open and exit-friendly
The trust between Laputa and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
---
## Why not Obsidian?
Obsidian is the obvious comparison. The difference is philosophy:
- **Obsidian** is a blank canvas. Infinitely configurable via plugins, themes, and community extensions. Great for power users who want to build their own system from scratch.
- **Laputa** is opinionated. It ships with a point of view on how to organize knowledge, with sensible defaults that work out of the box — no plugin hunting required.
- **Obsidian** is a blank canvas. Infinitely configurable via plugins and community extensions. Powerful for users who want to build their own system — and who have the time and patience to do so.
- **Laputa** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
Obsidian also treats Git as an afterthought (its business model is built around proprietary sync). In Laputa, **Git is a first-class citizen** — the obvious, natural way to sync and collaborate.
Obsidian also treats Git as an afterthought its business model is built around proprietary sync. In Laputa, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
## The knowledge ontology
---
Laputa is built around a clear conceptual model, inspired by PARA but adapted to Luca's real-world usage:
## Who it's for, and where it's going
**Two axes:**
1. *One-time* vs *recurring*
2. *Single-session* vs *multi-session*
### Three stages of adoption
**Four action types:**
- **Projects** — one-time, multi-session (have a start and end)
- **Responsibilities** — recurring, multi-session (no end, measured by KPIs)
- **Tasks** — one-time, single-session (live in Todoist)
- **Procedures** — recurring, single-session (checklists, routines)
Laputa is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
**Knowledge containers:**
- **Notes** — the atomic unit. Can belong to any of the above.
- **Topics** — areas of interest with no performance expectation (like labels/tags). E.g. "front-end engineering", "interior design"
- **Events** — things that happened, tied to a date
- **People** — contacts, with a log of interactions
**Stage 1: Personal PKM + AI context** *(current)*
A single person manages their knowledge, life, and work in a local vault. The primary collaborator is AI. The vault gives structure to one person's context, making it legible to an AI that can assist meaningfully across all areas of work and life. The method helps structure the knowledge; the AI helps use it.
**Relations** between notes are first-class citizens — not just wiki-links, but typed, bidirectional connections.
**Stage 2: Independent knowledge workers**
Content creators, freelancers, consultants. People with maximum incentive *and* maximum agency to build a real system. They have projects, clients, responsibilities — and they work alone or in very small teams. The same ontology applies: a newsletter creator has editorial projects, a subscriber-growth responsibility, and a publishing procedure. AI collaboration deepens: the AI can see not just personal notes but client commitments, content pipelines, recurring workflows.
## The deeper mission: AI context scaffolding
**Stage 3: Small teams**
The ontology scales to organizations. Companies have projects, responsibilities, procedures, and people — the same categories, at a larger scale. The access model changes: different people see different subsets of the vault, via workspace filtering and Git-based access control. Version history gives teams a full audit trail. AI agents become shared collaborators on team knowledge, not just personal assistants.
Most people today can't effectively share context about their lives with AI. They don't know what to write, how to structure it, or when. The result is that AI assistants — even the best ones — are working with a fraction of the context they need.
**What makes this trajectory coherent:** the foundational model — local files, Git-versioned, structured by conventions — doesn't need to be rebuilt at each stage. It extends naturally.
Laputa's goal is not just to be an efficient place to store things. It's to provide **scaffolding that makes it easy for people to externalize their knowledge in a structured, AI-readable way** — without having to figure out the system themselves.
### The right early adopters
The vision: a person who uses Laputa well has built a second brain that any AI can read, reason over, and contribute to. Not the naive "memory" that ChatGPT builds from chat history — but an intentional, curated, structured representation of their work and life.
## Target user (v1)
Developers and technically-minded knowledge workers who:
- Are frustrated with Notion's complexity or performance
The first users who will get the most from Laputa are technically-minded individuals who:
- Are frustrated with Notion's performance, complexity, or lock-in
- Understand or are comfortable with Git
- Want a system that's AI-native by design, not by bolted-on features
- Value owning their data and formats
- Value owning their data
Broader audiences (non-developers) are a future consideration — they'll need more onboarding and scaffolding to get started, but the underlying model is designed to work for anyone.
Broader audiences will follow as the onboarding experience matures and the conventions become easier to adopt.
---
## Current state
A living snapshot of what's built vs what's missing. Updated as features ship.
A living snapshot of what's built. Updated as features ship.
### ✅ What's working today
### ✅ Working today
**Core editor & notes**
- BlockNote-based editor (block-style, Notion-like) with Markdown files on disk
- Cmd+S save with dirty state indicator (orange dot = modified, green dot = new)
- Word count (frontmatter excluded)
- Rename note by double-clicking tab
- Drag & drop images into editor
- Wiki-links with `[[` autocomplete (2+ chars, max 20 results, colored by note type)
**Navigation & layout**
- BlockNote editor with Markdown files on disk; Cmd+S save; dirty state indicator
- 4-panel layout: sidebar / note list / editor / inspector
- Collapsible sidebar and note list (Cmd+1/2/3)
- Tabs with drag-to-reorder
- Quick open (Cmd+P) by title
- Virtual list rendering for NoteList (handles 9000+ notes without lag)
- Tabs with drag-to-reorder; quick open (Cmd+P); virtual list (handles 9000+ notes)
- `type:` as canonical frontmatter field; inspector with editable properties
- Bidirectional relationships; editable relation chips; wikilink autocomplete (`[[`)
- Sidebar sections with custom icons and colors
- Git integration: commit & push, version history per note, dirty state tracking
- Dynamic vault picker; create or clone vaults from GitHub
- GitHub OAuth (device flow); AI chat panel; Claude CLI agent panel
- Auto-updater; universal macOS binary; CI with coverage gates
**Properties & types**
- Inspector panel with editable vs read-only properties
- Change note type from Inspector (picker/dropdown)
- Property value text consistent at 12px
- URL properties: click to open in browser, underline on hover
- Bidirectional relationships (Referenced By panel)
- Editable relations: add/remove linked notes
- `type:` as canonical key (removed `is a:` property)
### 🚧 Ahead (consolidation sprint → features)
**Sections & customization**
- Sidebar sections with custom icons (290 Phosphor icons, searchable) and colors
- Changes view: click "N pending" in status bar → filtered list of modified notes
**Consolidation sprint (current):**
Fixing architectural foundations before building further — cache model, type field canonicalization, allContent removal, hardcoded paths.
**Git integration**
- Commit & push from within the app (saves pending changes first)
- Modified files indicator in status bar, NoteList, and TabBar
- Git history per note (version history)
- Dirty state clears correctly after save/rename
**Next features:**
Inbox section, semantic properties (status chips, progress indicators), default relationships in properties panel, workspace filter, mobile apps (iPhone for capture, iPad as desktop mirror).
**Vault management**
- Dynamic vault picker (no hardcoded paths)
- Create new local vault or clone/create from GitHub repo
- GitHub OAuth login (device flow)
*For the full roadmap, see [ROADMAP.md](./ROADMAP.md).*
**Settings & infrastructure**
- Settings panel (Cmd+,): AI provider API keys, stored in app_config_dir
- In-app auto-updater (Tauri updater + GitHub Releases)
- CI: lint, TypeScript, tests (84% frontend coverage, 85%+ Rust), CodeScene ≥9.2
- Universal macOS binary, auto-released on every merge to main
### 🚧 What's missing (Open tasks)
**Bugs**
- Word count still including some frontmatter in edge cases (under investigation)
**Improvements**
- Date picker for date-type properties
- Vista Changes: differentiate new vs modified more clearly
- Relation editing UX polish
**Features (prioritized)**
- Full-text search with semantic support (qmd integration)
- Command palette (Cmd+K) — Raycast-style actions
- `mock-tauri.ts` and `App.tsx` refactor (code health)
**Vision-level features (not started)**
- Onboarding / getting started flow with default note types
- AI-powered features (search, summarization, linking suggestions)
- Graph view
- Mobile / web access via Git remote
---
## Design principles
1. **Opinionated but not rigid** — ship strong defaults, allow customization where it matters
2. **Git-first** — sync, history, and collaboration via Git; no proprietary cloud
3. **AI-native architecture** — local files, open formats, readable by any AI tool
4. **Zero lock-in** — earn trust daily; the exit door is always open
5. **Ready out of the box** — no plugin hunting, no theme configuration; it just works
6. **Relations as first-class citizens** — connections between notes are as important as the notes themselves
1. **Opinionated but not rigid** — ship the method and the defaults; allow customization where it matters
2. **Convention over configuration** — standard field names trigger rich behavior automatically; users can override via vault config files
3. **Git-first** — sync, history, collaboration, and audit trail via Git; no proprietary cloud
4. **AI-native architecture** — local files, open formats, structured by conventions legible to both humans and AI
5. **Zero lock-in** — earn trust daily; the exit door is always open
6. **Capture and organize are separate** — the inbox makes unorganized notes visible; Inbox Zero is the discipline
7. **Relations as first-class citizens** — connections between notes are as important as the notes themselves
8. **Filesystem as the single source of truth** — the app never owns the data; cache and UI state are always derived and reconstructible

View File

@@ -3,7 +3,7 @@ import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/smoke',
timeout: 15_000,
retries: 0,
retries: 1,
workers: 1,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5201',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -13,7 +13,7 @@ use crate::indexing::{IndexStatus, IndexingProgress};
use crate::search::SearchResponse;
use crate::settings::Settings;
use crate::theme::{ThemeFile, VaultSettings};
use crate::vault::{RenameResult, VaultEntry};
use crate::vault::{MoveResult, RenameResult, VaultEntry};
use crate::vault_config::VaultConfig;
use crate::vault_list::VaultList;
use crate::{
@@ -90,6 +90,17 @@ pub fn rename_note(
vault::rename_note(&vault_path, &old_path, &new_title)
}
#[tauri::command]
pub fn move_note_to_type_folder(
vault_path: String,
note_path: String,
new_type: String,
) -> Result<MoveResult, String> {
let vault_path = expand_tilde(&vault_path);
let note_path = expand_tilde(&note_path);
vault::move_note_to_type_folder(&vault_path, &note_path, &new_type)
}
#[tauri::command]
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
@@ -128,6 +139,19 @@ pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
pub fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path);
vault::invalidate_cache(std::path::Path::new(path.as_ref()));
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn reload_vault_entry(path: String) -> Result<VaultEntry, String> {
let path = expand_tilde(&path);
vault::reload_entry(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
@@ -660,6 +684,83 @@ mod tests {
assert!(content.contains("Trashed at"));
}
#[test]
fn test_reload_vault_entry_reads_from_disk() {
let dir = tempfile::TempDir::new().unwrap();
let note = dir.path().join("note.md");
std::fs::write(&note, "---\nStatus: Active\n---\n# Test\n").unwrap();
let entry = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
assert_eq!(entry.title, "Test");
assert_eq!(entry.status, Some("Active".to_string()));
// Modify file on disk
std::fs::write(&note, "---\nStatus: Done\n---\n# Test\n").unwrap();
let fresh = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
assert_eq!(fresh.status, Some("Done".to_string()));
}
#[test]
fn test_reload_vault_entry_nonexistent() {
let result = reload_vault_entry("/nonexistent/path.md".to_string());
assert!(result.is_err());
}
#[test]
fn test_reload_vault_invalidates_cache_and_rescans() {
let dir = tempfile::TempDir::new().unwrap();
let vault = dir.path();
// Init git repo for caching to work
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "t@t.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "T"])
.current_dir(vault)
.output()
.unwrap();
// Set test cache dir to avoid polluting real cache
let cache_dir = tempfile::TempDir::new().unwrap();
std::env::set_var(
"LAPUTA_CACHE_DIR",
cache_dir.path().to_string_lossy().as_ref(),
);
std::fs::write(vault.join("note.md"), "---\nTrashed: false\n---\n# Note\n").unwrap();
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 via list_vault
let entries = list_vault(vault.to_str().unwrap().to_string()).unwrap();
assert!(!entries[0].trashed);
// Trash the note on disk
std::fs::write(vault.join("note.md"), "---\nTrashed: true\n---\n# Note\n").unwrap();
// reload_vault must return the updated trashed state
let fresh = reload_vault(vault.to_str().unwrap().to_string()).unwrap();
assert!(
fresh[0].trashed,
"reload_vault must reflect disk state after trashing"
);
}
#[test]
fn test_check_vault_exists_false() {
assert!(!check_vault_exists("/nonexistent/path/abc123".to_string()));

View File

@@ -44,7 +44,6 @@ pub fn init_repo(path: &str) -> Result<(), String> {
std::fs::write(
&gitignore_path,
"# Laputa app files (machine-specific, never commit)\n\
.laputa-cache.json\n\
.laputa/settings.json\n\
\n\
# macOS\n\
@@ -265,7 +264,7 @@ mod tests {
}
#[test]
fn test_init_repo_creates_gitignore_with_ds_store() {
fn test_init_repo_creates_gitignore() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
@@ -283,14 +282,15 @@ mod tests {
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"
);
// Cache is now stored outside the vault — no need for .gitignore entry
assert!(
!content.contains(".laputa-cache.json"),
".gitignore should NOT contain .laputa-cache.json (cache is external)"
);
}
#[test]

View File

@@ -118,6 +118,7 @@ pub fn run() {
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::move_note_to_type_folder,
commands::get_file_history,
commands::get_modified_files,
commands::get_file_diff,
@@ -136,6 +137,8 @@ pub fn run() {
commands::check_claude_cli,
commands::stream_claude_chat,
commands::stream_claude_agent,
commands::reload_vault,
commands::reload_vault_entry,
commands::save_image,
commands::copy_image_to_vault,
commands::purge_trash,

View File

@@ -49,6 +49,7 @@ 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_RELOAD: &str = "vault-reload";
const VAULT_REPAIR: &str = "vault-repair";
const CUSTOM_IDS: &[&str] = &[
@@ -91,6 +92,7 @@ const CUSTOM_IDS: &[&str] = &[
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
VAULT_RELOAD,
VAULT_REPAIR,
];
@@ -339,6 +341,9 @@ fn build_vault_menu(app: &App) -> MenuResult {
let reindex = MenuItemBuilder::new("Reindex Vault")
.id(VAULT_REINDEX)
.build(app)?;
let reload = MenuItemBuilder::new("Reload Vault")
.id(VAULT_RELOAD)
.build(app)?;
let repair = MenuItemBuilder::new("Repair Vault")
.id(VAULT_REPAIR)
.build(app)?;
@@ -356,6 +361,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
.item(&view_changes)
.separator()
.item(&reindex)
.item(&reload)
.item(&repair)
.item(&install_mcp)
.build()?)
@@ -475,6 +481,7 @@ mod tests {
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
VAULT_RELOAD,
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");

View File

@@ -1,4 +1,5 @@
use crate::indexing;
use crate::vault;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
@@ -160,16 +161,19 @@ pub fn search_vault(
let results: Vec<SearchResult> = qmd_results
.into_iter()
.map(|r| {
.filter_map(|r| {
let path = qmd_uri_to_vault_path(&r.file, vault_path);
if vault::is_file_trashed(Path::new(&path)) {
return None;
}
let snippet = extract_clean_snippet(&r.snippet);
SearchResult {
Some(SearchResult {
title: r.title,
path,
snippet,
score: r.score,
note_type: None,
}
})
})
.collect();

View File

@@ -164,7 +164,7 @@ pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
/// Vault-based theme note for the built-in Default theme.
pub const DEFAULT_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
type: Theme\n\
Description: Light theme with warm, paper-like tones\n\
background: \"#FFFFFF\"\n\
foreground: \"#37352F\"\n\
@@ -220,7 +220,7 @@ The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
/// Vault-based theme note for the built-in Dark theme.
pub const DARK_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
type: Theme\n\
Description: Dark variant with deep navy tones\n\
background: \"#0f0f1a\"\n\
foreground: \"#e0e0e0\"\n\
@@ -276,7 +276,7 @@ A dark theme with deep navy tones for comfortable night-time reading.\n";
/// Vault-based theme note for the built-in Minimal theme.
pub const MINIMAL_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
type: Theme\n\
Description: High contrast, minimal chrome\n\
background: \"#FAFAFA\"\n\
foreground: \"#111111\"\n\
@@ -332,7 +332,7 @@ 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\
type: Type\n\
icon: palette\n\
color: purple\n\
order: 50\n\

View File

@@ -181,7 +181,7 @@ mod tests {
seed_vault_themes(vp);
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
assert!(content.contains("type: Theme"));
}
#[test]
@@ -190,7 +190,7 @@ mod tests {
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
let custom = "---\ntype: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
@@ -227,7 +227,7 @@ mod tests {
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
assert!(content.contains("type: Theme"));
}
#[test]
@@ -236,7 +236,7 @@ mod tests {
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
let custom = "---\ntype: Theme\nbackground: \"#123456\"\n---\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
@@ -265,7 +265,7 @@ mod tests {
"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("type: Type"));
assert!(type_content.contains("icon: palette"));
}
@@ -280,7 +280,7 @@ mod tests {
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("type: Type"));
assert!(content.contains("icon: palette"));
}
@@ -290,7 +290,7 @@ mod tests {
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";
let custom = "---\ntype: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
fs::write(type_dir.join("theme.md"), custom).unwrap();
let vp = vault.to_str().unwrap();

View File

@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use super::{parse_md_file, scan_vault, VaultEntry};
@@ -25,7 +26,30 @@ fn default_cache_version() -> u32 {
1
}
fn cache_path(vault: &Path) -> std::path::PathBuf {
/// Compute a deterministic hex hash of the vault path for use as cache filename.
fn vault_path_hash(vault: &Path) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
vault.to_string_lossy().as_ref().hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// Return the cache directory. Override with `LAPUTA_CACHE_DIR` env var (for tests).
fn cache_dir() -> PathBuf {
if let Ok(dir) = std::env::var("LAPUTA_CACHE_DIR") {
return PathBuf::from(dir);
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".laputa")
.join("cache")
}
fn cache_path(vault: &Path) -> PathBuf {
cache_dir().join(format!("{}.json", vault_path_hash(vault)))
}
/// Legacy cache path inside the vault directory (pre-migration).
fn legacy_cache_path(vault: &Path) -> PathBuf {
vault.join(".laputa-cache.json")
}
@@ -123,11 +147,18 @@ fn load_cache(vault: &Path) -> Option<VaultCache> {
serde_json::from_str(&data).ok()
}
/// Write cache atomically: write to a temp file then rename.
fn write_cache(vault: &Path, cache: &VaultCache) {
if let Ok(data) = serde_json::to_string(cache) {
let _ = fs::write(cache_path(vault), data);
let final_path = cache_path(vault);
if let Some(parent) = final_path.parent() {
let _ = fs::create_dir_all(parent);
}
let tmp_path = final_path.with_extension("tmp");
if let Ok(data) = serde_json::to_string(cache) {
if fs::write(&tmp_path, &data).is_ok() {
let _ = fs::rename(&tmp_path, &final_path);
}
}
ensure_cache_excluded(vault);
}
/// Normalize an absolute path to a relative path for comparison with git output.
@@ -156,52 +187,67 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
.collect()
}
/// 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"];
/// Copy legacy cache data to the new external location atomically.
fn copy_legacy_cache_to(legacy: &Path, dest: &Path) {
if let Some(parent) = dest.parent() {
let _ = fs::create_dir_all(parent);
}
let tmp_path = dest.with_extension("tmp");
if let Ok(data) = fs::read_to_string(legacy) {
if fs::write(&tmp_path, &data).is_ok() {
let _ = fs::rename(&tmp_path, dest);
}
}
}
/// 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 git_dir = vault.join(".git");
if !git_dir.is_dir() {
/// Migrate legacy cache from inside the vault to the new external location.
/// Also removes the legacy file from git tracking if present.
fn migrate_legacy_cache(vault: &Path) {
let legacy = legacy_cache_path(vault);
if !legacy.exists() {
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"));
let new_path = cache_path(vault);
if !new_path.exists() {
copy_legacy_cache_to(&legacy, &new_path);
}
// 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.
// Remove legacy file from git tracking if present
let _ = std::process::Command::new("git")
.args(["rm", "--cached", "--quiet", "--ignore-unmatch", "--"])
.args(UNTRACKED_FILES)
.args([
"rm",
"--cached",
"--quiet",
"--ignore-unmatch",
".laputa-cache.json",
])
.current_dir(vault)
.output();
// Delete the legacy file from disk
let _ = fs::remove_file(&legacy);
}
/// Remove entries for files that no longer exist on disk and deduplicate
/// by case-folded relative path (handles case-insensitive filesystems like macOS APFS).
/// Returns `true` if any entries were removed.
fn prune_stale_entries(vault: &Path, entries: &mut Vec<VaultEntry>) -> bool {
let before = entries.len();
// Remove entries whose files no longer exist on disk
entries.retain(|e| std::path::Path::new(&e.path).is_file());
// Deduplicate by case-folded relative path
let mut seen = std::collections::HashSet::new();
entries.retain(|e| {
let rel = to_relative_path(&e.path, vault).to_lowercase();
seen.insert(rel)
});
entries.len() != before
}
/// Sort entries by modified_at descending and write the cache.
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
prune_stale_entries(vault, &mut entries);
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
write_cache(
vault,
@@ -216,18 +262,18 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
}
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
/// Always prunes stale entries even when git reports no changes, so that files
/// deleted outside git (e.g., via Finder) are removed from the cache on vault open.
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
let changed = git_uncommitted_files(vault);
if changed.is_empty() {
return cache.entries;
let mut entries = cache.entries;
if !changed.is_empty() {
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault)));
entries.extend(parse_files_at(vault, &changed));
}
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
let mut entries: Vec<VaultEntry> = cache
.entries
.into_iter()
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
.collect();
entries.extend(parse_files_at(vault, &changed));
// Always finalize: prune_stale_entries inside finalize_and_cache removes
// entries for files deleted outside git (e.g., via Finder or another app).
finalize_and_cache(vault, entries, cache.commit_hash)
}
@@ -250,6 +296,14 @@ fn update_different_commit(
finalize_and_cache(vault, entries, current_hash)
}
/// Delete the cache file for a vault, forcing a full rescan on the next
/// call to `scan_vault_cached`. Used by the `reload_vault` command so that
/// explicit user-triggered reloads always read from the filesystem.
pub fn invalidate_cache(vault_path: &Path) {
let path = cache_path(vault_path);
let _ = fs::remove_file(&path);
}
/// Scan vault with incremental caching via git.
/// Falls back to full scan if cache is missing/corrupt or git is unavailable.
pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
@@ -260,6 +314,9 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
));
}
// Migrate legacy in-vault cache to external location on first run
migrate_legacy_cache(vault_path);
let current_hash = match git_head_hash(vault_path) {
Some(h) => h,
None => return scan_vault(vault_path),
@@ -289,8 +346,19 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
mod tests {
use super::*;
use std::io::Write;
use std::sync::Mutex;
use tempfile::TempDir;
/// Serialize all cache tests that mutate the LAPUTA_CACHE_DIR env var.
/// `std::env::set_var` is process-global, so parallel tests would race.
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// Set up a temporary cache directory for test isolation.
/// Caller MUST hold `ENV_LOCK` for the duration of the test.
fn set_test_cache_dir(dir: &Path) {
std::env::set_var("LAPUTA_CACHE_DIR", dir.to_string_lossy().as_ref());
}
fn create_test_file(dir: &Path, name: &str, content: &str) {
let file_path = dir.join(name);
if let Some(parent) = file_path.parent() {
@@ -300,8 +368,157 @@ mod tests {
file.write_all(content.as_bytes()).unwrap();
}
fn init_git_repo(vault: &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();
}
/// Common setup: acquire env lock, create temp cache dir + git-initialised vault.
/// Returns (lock_guard, cache_tmpdir, vault_tmpdir) — keep all alive for the test.
fn setup_git_vault() -> (std::sync::MutexGuard<'static, ()>, TempDir, TempDir) {
let lock = ENV_LOCK.lock().unwrap();
let cache_tmp = TempDir::new().unwrap();
set_test_cache_dir(cache_tmp.path());
let vault_tmp = TempDir::new().unwrap();
init_git_repo(vault_tmp.path());
(lock, cache_tmp, vault_tmp)
}
fn git_add_commit(vault: &Path, msg: &str) {
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", msg])
.current_dir(vault)
.output()
.unwrap();
}
#[test]
fn test_cache_path_is_outside_vault() {
let _lock = ENV_LOCK.lock().unwrap();
let cache_dir = TempDir::new().unwrap();
set_test_cache_dir(cache_dir.path());
let vault = Path::new("/Users/test/MyVault");
let path = cache_path(vault);
// Cache must NOT be inside the vault
assert!(
!path.starts_with(vault),
"cache path must be outside the vault, got: {}",
path.display()
);
// Cache must be under the cache directory
assert!(
path.starts_with(cache_dir.path()),
"cache path must be under cache dir, got: {}",
path.display()
);
// Must end with .json
assert_eq!(path.extension().unwrap(), "json");
}
#[test]
fn test_vault_path_hash_is_deterministic() {
let hash1 = vault_path_hash(Path::new("/Users/test/MyVault"));
let hash2 = vault_path_hash(Path::new("/Users/test/MyVault"));
assert_eq!(hash1, hash2);
}
#[test]
fn test_different_vaults_get_different_hashes() {
let hash1 = vault_path_hash(Path::new("/Users/test/Vault1"));
let hash2 = vault_path_hash(Path::new("/Users/test/Vault2"));
assert_ne!(hash1, hash2);
}
#[test]
fn test_atomic_write_no_tmp_file_left() {
let _lock = ENV_LOCK.lock().unwrap();
let cache_dir = TempDir::new().unwrap();
set_test_cache_dir(cache_dir.path());
let vault_dir = TempDir::new().unwrap();
let vault = vault_dir.path();
let cache = VaultCache {
version: CACHE_VERSION,
vault_path: vault.to_string_lossy().to_string(),
commit_hash: "abc123".to_string(),
entries: vec![],
};
write_cache(vault, &cache);
// Final file should exist
let final_path = cache_path(vault);
assert!(final_path.exists(), "cache file must exist after write");
// Tmp file should NOT exist (renamed away)
let tmp_path = final_path.with_extension("tmp");
assert!(
!tmp_path.exists(),
"tmp file must not exist after atomic write"
);
// Content must be valid JSON
let data = fs::read_to_string(&final_path).unwrap();
let loaded: VaultCache = serde_json::from_str(&data).unwrap();
assert_eq!(loaded.commit_hash, "abc123");
}
#[test]
fn test_legacy_cache_migration() {
let (_lock, _cache_tmp, vault_dir) = setup_git_vault();
let vault = vault_dir.path();
// Create a legacy cache file inside the vault
let legacy = legacy_cache_path(vault);
let cache = VaultCache {
version: CACHE_VERSION,
vault_path: vault.to_string_lossy().to_string(),
commit_hash: "old123".to_string(),
entries: vec![],
};
fs::write(&legacy, serde_json::to_string(&cache).unwrap()).unwrap();
// Run migration
migrate_legacy_cache(vault);
// New cache file should exist with migrated data
let new_path = cache_path(vault);
assert!(new_path.exists(), "migrated cache must exist");
let data = fs::read_to_string(&new_path).unwrap();
let loaded: VaultCache = serde_json::from_str(&data).unwrap();
assert_eq!(loaded.commit_hash, "old123");
// Legacy file should be deleted
assert!(!legacy.exists(), "legacy cache file must be removed");
}
#[test]
fn test_scan_vault_cached_no_git() {
let _lock = ENV_LOCK.lock().unwrap();
let cache_dir = TempDir::new().unwrap();
set_test_cache_dir(cache_dir.path());
// Without git, scan_vault_cached falls back to scan_vault
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "note.md", "# Note\n\nContent here.");
@@ -314,43 +531,23 @@ mod tests {
#[test]
fn test_scan_vault_cached_with_git() {
let dir = TempDir::new().unwrap();
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
// Init git repo
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "note.md", "# Note\n\nFirst version.");
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();
git_add_commit(vault, "init");
// First call: full scan, writes cache
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(cache_path(vault).exists());
// Cache must NOT be inside the vault
assert!(
!cache_path(vault).starts_with(vault),
"cache must be outside the vault"
);
// Second call: uses cache (same HEAD)
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1);
@@ -359,37 +556,11 @@ mod tests {
#[test]
fn test_scan_vault_cached_invalidates_stale_vault_path() {
let dir = TempDir::new().unwrap();
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
// Init git repo
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "note.md", "# Note\n\nContent.");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
git_add_commit(vault, "init");
// Build cache normally
let entries = scan_vault_cached(vault).unwrap();
@@ -424,37 +595,11 @@ mod tests {
#[test]
fn test_scan_vault_cached_incremental_different_commit() {
let dir = TempDir::new().unwrap();
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
// Init git repo
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "first.md", "# First\n\nFirst note.");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "first"])
.current_dir(vault)
.output()
.unwrap();
git_add_commit(vault, "first");
// Build cache
let entries = scan_vault_cached(vault).unwrap();
@@ -462,16 +607,7 @@ mod tests {
// Add a second file and commit
create_test_file(vault, "second.md", "# Second\n\nSecond note.");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "second"])
.current_dir(vault)
.output()
.unwrap();
git_add_commit(vault, "second");
// Incremental update: cache has old commit, new commit adds second.md
let entries2 = scan_vault_cached(vault).unwrap();
@@ -483,37 +619,12 @@ mod tests {
#[test]
fn test_update_same_commit_picks_up_modified_file() {
let dir = TempDir::new().unwrap();
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
// Commit a type note without sidebar label
create_test_file(vault, "type/news.md", "---\ntype: Type\n---\n# News\n");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
git_add_commit(vault, "init");
// Prime the cache (same commit hash)
let entries = scan_vault_cached(vault).unwrap();
@@ -539,36 +650,11 @@ mod tests {
#[test]
fn test_update_same_commit_new_file_still_added() {
let dir = TempDir::new().unwrap();
let (_lock, _cache_tmp, dir) = setup_git_vault();
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();
git_add_commit(vault, "init");
// Prime cache
let entries = scan_vault_cached(vault).unwrap();
@@ -587,36 +673,11 @@ mod tests {
#[test]
fn test_update_same_commit_new_files_in_new_subdirectory() {
let dir = TempDir::new().unwrap();
let (_lock, _cache_tmp, dir) = setup_git_vault();
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();
git_add_commit(vault, "init");
// Prime cache
let entries = scan_vault_cached(vault).unwrap();
@@ -649,41 +710,16 @@ mod tests {
#[test]
fn test_update_same_commit_visible_removed_from_type_note() {
let dir = TempDir::new().unwrap();
let (_lock, _cache_tmp, dir) = setup_git_vault();
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();
git_add_commit(vault, "init");
// Prime the cache
let entries = scan_vault_cached(vault).unwrap();
@@ -705,4 +741,133 @@ mod tests {
"visible must be None after removing the field"
);
}
#[test]
fn test_deleted_file_removed_from_cache_on_rescan() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(vault, "keep.md", "# Keep\n\nStays.");
create_test_file(vault, "remove.md", "# Remove\n\nGoes away.");
git_add_commit(vault, "init");
// Prime cache with both files
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 2);
// Delete file via filesystem (simulates Finder delete)
fs::remove_file(vault.join("remove.md")).unwrap();
// Also stage the deletion so git status is clean for this file
std::process::Command::new("git")
.args(["add", "remove.md"])
.current_dir(vault)
.output()
.unwrap();
// Rescan — deleted file must be pruned
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1, "deleted file must be pruned on rescan");
assert_eq!(entries2[0].title, "Keep");
}
#[test]
fn test_deleted_untracked_file_removed_from_cache() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(vault, "tracked.md", "# Tracked\n\nCommitted.");
git_add_commit(vault, "init");
// Create untracked file and prime cache
create_test_file(vault, "temp.md", "# Temp\n\nUntracked.");
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 2);
// Delete the untracked file via filesystem
fs::remove_file(vault.join("temp.md")).unwrap();
// Rescan — untracked deleted file must be pruned
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(
entries2.len(),
1,
"deleted untracked file must be pruned on rescan"
);
assert_eq!(entries2[0].title, "Tracked");
}
#[test]
fn test_case_rename_no_duplicates() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(vault, "Note.md", "# Note\n\nOriginal case.");
git_add_commit(vault, "init");
// Prime cache
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
// Simulate case-only rename on case-insensitive FS: delete old, create new
fs::remove_file(vault.join("Note.md")).unwrap();
create_test_file(vault, "note.md", "# Note\n\nRenamed case.");
git_add_commit(vault, "rename");
// Rescan — must not have duplicates
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(
entries2.len(),
1,
"case-only rename must not create duplicates"
);
}
#[test]
fn test_invalidate_cache_deletes_cache_file() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(vault, "note.md", "# Note\n\nContent.");
git_add_commit(vault, "init");
// Build cache
let _ = scan_vault_cached(vault).unwrap();
assert!(cache_path(vault).exists(), "cache file must exist");
// Invalidate
invalidate_cache(vault);
assert!(
!cache_path(vault).exists(),
"cache file must be deleted after invalidation"
);
}
#[test]
fn test_invalidate_then_scan_forces_full_rescan() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
create_test_file(vault, "note.md", "---\nTrashed: false\n---\n# Note\n");
git_add_commit(vault, "init");
// Build cache — note is not trashed
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(!entries[0].trashed, "note must not be trashed initially");
// Simulate trashing the note on disk (update frontmatter directly)
create_test_file(vault, "note.md", "---\nTrashed: true\n---\n# Note\n");
// Stage the change so git sees it
git_add_commit(vault, "trash");
// Without invalidation, scan_vault_cached uses incremental update.
// With invalidation, it must do a full rescan from disk.
invalidate_cache(vault);
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1);
assert!(
entries2[0].trashed,
"note must be trashed after invalidate + rescan"
);
}
}

View File

@@ -51,7 +51,7 @@ YAML frontmatter between `---` delimiters defines metadata:
```yaml
---
Is A: Project
type: Project
Status: Active
Owner: "[[person/jane-doe]]"
Belongs to: "[[quarter/24q1]]"
@@ -65,7 +65,7 @@ Related to:
| Field | Purpose |
|-------|---------|
| `Is A` | Entity type (usually inferred from folder) |
| `type` | Entity type (usually inferred from folder) |
| `Status` | Active, Done, Paused, Archived, Dropped |
| `Owner` | Person responsible (wikilink) |
| `Belongs to` | Parent relationship(s) |
@@ -98,7 +98,7 @@ Files in `type/` define entity types and control how they appear in the sidebar:
```yaml
---
Is A: Type
type: Type
icon: rocket-launch
color: purple
order: 1
@@ -119,32 +119,32 @@ Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor n
const SAMPLE_FILES: &[SampleFile] = &[
SampleFile {
rel_path: "type/project.md",
content: "---\nIs A: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n",
content: "---\ntype: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n",
},
SampleFile {
rel_path: "type/note.md",
content: "---\nIs A: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n",
content: "---\ntype: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n",
},
SampleFile {
rel_path: "type/person.md",
content: "---\nIs A: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n",
content: "---\ntype: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n",
},
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",
content: "---\ntype: 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",
content: "---\ntype: 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",
content: "---\ntype: 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#"---
Is A: Note
type: Note
Related to:
- "[[note/editor-basics]]"
- "[[note/using-properties]]"
@@ -179,7 +179,7 @@ Every note is a markdown file with optional YAML frontmatter at the top. Notes l
SampleFile {
rel_path: "note/editor-basics.md",
content: r#"---
Is A: Note
type: Note
Related to: "[[note/welcome-to-laputa]]"
---
@@ -227,7 +227,7 @@ function hello() {
SampleFile {
rel_path: "note/using-properties.md",
content: r#"---
Is A: Note
type: Note
Status: Active
Related to:
- "[[note/welcome-to-laputa]]"
@@ -240,7 +240,7 @@ Every note can have **properties** defined in the YAML frontmatter at the top of
## Common properties
- **Is A** — The note's type (Project, Note, Person, etc.)
- **type** — The note's type (Project, Note, Person, etc.)
- **Status** — Current state: Active, Done, Paused, Archived, Dropped
- **Belongs to** — Parent relationship (e.g., a project belongs to a quarter)
- **Related to** — Lateral connections to other notes
@@ -261,7 +261,7 @@ You can add any custom property. If the value contains `[[wiki-links]]`, Laputa
SampleFile {
rel_path: "note/wiki-links-and-relationships.md",
content: r#"---
Is A: Note
type: Note
Related to:
- "[[note/welcome-to-laputa]]"
- "[[note/using-properties]]"
@@ -300,7 +300,7 @@ Over time, your wiki-links form a rich web of connections. Use the **Referenced
SampleFile {
rel_path: "project/sample-project.md",
content: r#"---
Is A: Project
type: Project
Status: Active
Owner: "[[person/sample-collaborator]]"
Related to: "[[topic/getting-started]]"
@@ -329,7 +329,7 @@ This project is owned by [[person/sample-collaborator]] and relates to [[topic/g
SampleFile {
rel_path: "person/sample-collaborator.md",
content: r#"---
Is A: Person
type: Person
---
# Sample Collaborator
@@ -350,7 +350,7 @@ This person is the owner of [[project/sample-project]]. Check the **Referenced B
SampleFile {
rel_path: "topic/getting-started.md",
content: r#"---
Is A: Topic
type: Topic
---
# Getting Started

View File

@@ -7,13 +7,13 @@ mod parsing;
mod rename;
mod trash;
pub use cache::scan_vault_cached;
pub use cache::{invalidate_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;
pub use rename::{rename_note, RenameResult};
pub use trash::{delete_note, purge_trash};
pub use rename::{move_note_to_type_folder, rename_note, MoveResult, RenameResult};
pub use trash::{delete_note, is_file_trashed, purge_trash};
use parsing::{
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
@@ -94,7 +94,7 @@ pub struct VaultEntry {
/// Intermediate struct to capture YAML frontmatter fields.
#[derive(Debug, Deserialize, Default)]
struct Frontmatter {
#[serde(rename = "Is A", alias = "type")]
#[serde(rename = "type", alias = "Is A", alias = "is_a")]
is_a: Option<StringOrList>,
#[serde(default)]
aliases: Option<StringOrList>,
@@ -108,9 +108,19 @@ struct Frontmatter {
owner: Option<String>,
#[serde(rename = "Cadence")]
cadence: Option<String>,
#[serde(rename = "Archived")]
#[serde(
rename = "Archived",
alias = "archived",
default,
deserialize_with = "deserialize_bool_or_string"
)]
archived: Option<bool>,
#[serde(rename = "Trashed", alias = "trashed")]
#[serde(
rename = "Trashed",
alias = "trashed",
default,
deserialize_with = "deserialize_bool_or_string"
)]
trashed: Option<bool>,
#[serde(rename = "Trashed at", alias = "trashed_at")]
trashed_at: Option<String>,
@@ -136,6 +146,56 @@ struct Frontmatter {
visible: Option<bool>,
}
/// Custom deserializer for boolean fields that may arrive as strings.
/// YAML `Yes`/`No` get converted to JSON strings by gray_matter, so we
/// need to accept both actual booleans and their string representations.
fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct BoolOrStringVisitor;
impl<'de> de::Visitor<'de> for BoolOrStringVisitor {
type Value = Option<bool>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a boolean or a string representing a boolean")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
Ok(Some(v))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
match v.to_lowercase().as_str() {
"true" | "yes" | "1" => Ok(Some(true)),
"false" | "no" | "0" | "" => Ok(Some(false)),
_ => Ok(Some(false)),
}
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
Ok(Some(v != 0))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(Some(v != 0))
}
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
Ok(None)
}
}
deserializer.deserialize_any(BoolOrStringVisitor)
}
/// Handles YAML fields that can be either a single string or a list of strings.
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
@@ -435,6 +495,15 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
}
}
/// Re-read a single file from disk and return a fresh VaultEntry.
/// Used after failed optimistic updates to restore the true filesystem state.
pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
if !path.exists() {
return Err(format!("File does not exist: {}", path.display()));
}
parse_md_file(path)
}
/// Read the content of a single note file.
pub fn get_note_content(path: &Path) -> Result<String, String> {
if !path.exists() {
@@ -543,6 +612,35 @@ mod tests {
parse_md_file(&dir.path().join(name)).unwrap()
}
#[test]
fn test_reload_entry_returns_fresh_data() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"note.md",
"---\nStatus: Active\n---\n# My Note\n\nOriginal.",
);
let entry = reload_entry(&dir.path().join("note.md")).unwrap();
assert_eq!(entry.title, "My Note");
assert_eq!(entry.status, Some("Active".to_string()));
// Modify on disk and reload — must see the new content
create_test_file(
dir.path(),
"note.md",
"---\nStatus: Done\n---\n# My Note\n\nUpdated.",
);
let fresh = reload_entry(&dir.path().join("note.md")).unwrap();
assert_eq!(fresh.status, Some("Done".to_string()));
}
#[test]
fn test_reload_entry_nonexistent_file() {
let result = reload_entry(std::path::Path::new("/nonexistent/path/note.md"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
const FULL_FM_CONTENT: &str = "---\nIs A: Project\naliases:\n - Laputa\n - Castle in the Sky\nBelongs to:\n - Studio Ghibli\nRelated to:\n - Miyazaki\nStatus: Active\nOwner: Luca\nCadence: Weekly\n---\n# Laputa Project\n\nThis is a project note.\n";
#[test]
@@ -1374,6 +1472,25 @@ Company: Acme Corp
);
}
#[test]
fn test_parse_archived_lowercase_alias() {
let dir = TempDir::new().unwrap();
let content = "---\narchived: true\n---\n# Old Quarter\n";
let entry = parse_test_entry(&dir, "old-quarter.md", content);
assert!(
entry.archived,
"lowercase 'archived' must be parsed via alias (frontend writes lowercase)"
);
}
#[test]
fn test_parse_archived_titlecase() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: true\n---\n# Old Quarter\n";
let entry = parse_test_entry(&dir, "old-quarter-2.md", content);
assert!(entry.archived, "titlecase 'Archived' must also be parsed");
}
#[test]
fn test_trashed_false_when_absent() {
let dir = TempDir::new().unwrap();
@@ -1383,6 +1500,91 @@ Company: Acme Corp
assert!(entry.trashed_at.is_none());
}
// --- archived/trashed string-value tests ---
#[test]
fn test_parse_archived_yes_titlecase() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: Yes\n---\n# Old\n";
let entry = parse_test_entry(&dir, "old.md", content);
assert!(entry.archived, "'Archived: Yes' must be parsed as true");
}
#[test]
fn test_parse_archived_yes_lowercase() {
let dir = TempDir::new().unwrap();
let content = "---\narchived: yes\n---\n# Old\n";
let entry = parse_test_entry(&dir, "old2.md", content);
assert!(entry.archived, "'archived: yes' must be parsed as true");
}
#[test]
fn test_parse_archived_yes_uppercase() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: YES\n---\n# Old\n";
let entry = parse_test_entry(&dir, "old3.md", content);
assert!(entry.archived, "'Archived: YES' must be parsed as true");
}
#[test]
fn test_parse_archived_no() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: No\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active2.md", content);
assert!(!entry.archived, "'Archived: No' must be parsed as false");
}
#[test]
fn test_parse_archived_false_string() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: \"false\"\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active3.md", content);
assert!(
!entry.archived,
"'Archived: \"false\"' must be parsed as false"
);
}
#[test]
fn test_parse_archived_zero() {
let dir = TempDir::new().unwrap();
let content = "---\nArchived: 0\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active4.md", content);
assert!(!entry.archived, "'Archived: 0' must be parsed as false");
}
#[test]
fn test_parse_archived_absent() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Note\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active5.md", content);
assert!(!entry.archived, "absent archived must default to false");
}
#[test]
fn test_parse_trashed_yes_titlecase() {
let dir = TempDir::new().unwrap();
let content = "---\nTrashed: Yes\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone2.md", content);
assert!(entry.trashed, "'Trashed: Yes' must be parsed as true");
}
#[test]
fn test_parse_trashed_yes_lowercase() {
let dir = TempDir::new().unwrap();
let content = "---\ntrashed: yes\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone3.md", content);
assert!(entry.trashed, "'trashed: yes' must be parsed as true");
}
#[test]
fn test_parse_trashed_no() {
let dir = TempDir::new().unwrap();
let content = "---\nTrashed: No\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active6.md", content);
assert!(!entry.trashed, "'Trashed: No' must be parsed as false");
}
// --- visible field tests ---
#[test]
@@ -1425,6 +1627,32 @@ Company: Acme Corp
assert!(entry.properties.get("visible").is_none());
}
// --- round-trip: canonical `type:` field and `Is A:` alias ---
#[test]
fn test_roundtrip_type_key_parses_correctly() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Quarter\n---\n# Q1 2026\n";
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
assert_eq!(entry.is_a, Some("Quarter".to_string()));
}
#[test]
fn test_roundtrip_is_a_alias_still_works() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Quarter\n---\n# Q1 2026\n";
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
assert_eq!(entry.is_a, Some("Quarter".to_string()));
}
#[test]
fn test_roundtrip_is_a_snake_case_alias_still_works() {
let dir = TempDir::new().unwrap();
let content = "---\nis_a: Quarter\n---\n# Q1 2026\n";
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
assert_eq!(entry.is_a, Some("Quarter".to_string()));
}
// 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

@@ -18,12 +18,18 @@ pub(super) fn extract_title(content: &str, filename: &str) -> String {
}
/// Remove YAML frontmatter (triple-dash delimited) from content.
/// The closing `---` must appear at the start of a line to avoid matching
/// occurrences inside frontmatter values (e.g. `title: foo---bar`).
fn strip_frontmatter(content: &str) -> &str {
let Some(rest) = content.strip_prefix("---") else {
return content;
};
match rest.find("---") {
Some(end) => rest[end + 3..].trim_start(),
// Find closing `---` at the start of a line (preceded by newline)
match rest.find("\n---") {
Some(end) => {
let after = end + 4; // skip past "\n---"
rest[after..].trim_start()
}
None => content,
}
}
@@ -384,6 +390,46 @@ mod tests {
assert_eq!(count_body_words(content), 6);
}
// --- strip_frontmatter tests ---
#[test]
fn test_strip_frontmatter_basic() {
let content = "---\ntitle: Test\n---\nBody content.";
assert_eq!(strip_frontmatter(content), "Body content.");
}
#[test]
fn test_strip_frontmatter_no_frontmatter() {
let content = "Just plain content.";
assert_eq!(strip_frontmatter(content), "Just plain content.");
}
#[test]
fn test_strip_frontmatter_dashes_in_value() {
// The closing --- must be at line start, not inside a value
let content = "---\ntitle: foo---bar\nstatus: active\n---\nBody here.";
assert_eq!(strip_frontmatter(content), "Body here.");
}
#[test]
fn test_strip_frontmatter_unclosed() {
let content = "---\ntitle: Test\nNo closing fence";
assert_eq!(strip_frontmatter(content), content);
}
#[test]
fn test_strip_frontmatter_empty_body() {
let content = "---\ntitle: Test\n---\n";
assert_eq!(strip_frontmatter(content), "");
}
#[test]
fn test_count_body_words_with_dashes_in_frontmatter_value() {
// Regression: strip_frontmatter previously matched --- inside values
let content = "---\ntitle: my---note\nstatus: active\n---\n# Title\n\nThree body words.";
assert_eq!(count_body_words(content), 3);
}
// --- strip_markdown_chars tests ---
#[test]

View File

@@ -166,6 +166,183 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
.unwrap_or(abs_path)
}
/// Result of a move-to-type-folder operation.
#[derive(Debug, Serialize, Deserialize)]
pub struct MoveResult {
/// New absolute file path after move (same as old if no move happened).
pub new_path: String,
/// Number of other files updated (wikilink replacements).
pub updated_links: usize,
/// Whether the file was actually moved (false if already in the right folder).
pub moved: bool,
}
/// Convert a type name to a folder slug. All known types are single lowercase words;
/// unknown types are slugified (lowercase, non-alphanumeric → hyphen).
fn type_to_folder_slug(type_name: &str) -> String {
type_name
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<&str>>()
.join("-")
}
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
let dest = dest_dir.join(filename);
if !dest.exists() {
return dest;
}
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(filename)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
let mut counter = 2;
loop {
let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
if !candidate.exists() {
return candidate;
}
counter += 1;
}
}
/// Move a note to the folder corresponding to its new type, and update wikilinks across the vault.
///
/// Returns `MoveResult` with `moved: false` if the note is already in the correct folder.
/// Creates the target folder if it does not exist.
pub fn move_note_to_type_folder(
vault_path: &str,
note_path: &str,
new_type: &str,
) -> Result<MoveResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(note_path);
if !old_file.exists() {
return Err(format!("File does not exist: {}", note_path));
}
let new_type = new_type.trim();
if new_type.is_empty() {
return Err("Type cannot be empty".to_string());
}
let folder_slug = type_to_folder_slug(new_type);
// Check if already in the correct folder
let current_folder = old_file
.parent()
.and_then(|p| p.file_name())
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
if current_folder == folder_slug {
return Ok(MoveResult {
new_path: note_path.to_string(),
updated_links: 0,
moved: false,
});
}
let filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
// Create target directory if needed
let dest_dir = vault.join(&folder_slug);
if !dest_dir.exists() {
fs::create_dir_all(&dest_dir)
.map_err(|e| format!("Failed to create directory {}: {}", dest_dir.display(), e))?;
}
// Determine destination path (handle collisions)
let new_file = unique_dest_path(&dest_dir, &filename);
let new_path_str = new_file.to_string_lossy().to_string();
// Read content and move
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
fs::write(&new_file, &content)
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
fs::remove_file(old_file)
.map_err(|e| format!("Failed to remove old file {}: {}", note_path, e))?;
// Extract title for wikilink matching
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let old_title = super::extract_title(&content, &old_filename);
// Update wikilinks across the vault (title stays the same, path changes)
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(note_path, &vault_prefix);
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix);
// Build pattern matching old path stem (e.g. "note/weekly-review")
let re = match build_wikilink_pattern(&old_title, old_path_stem) {
Some(r) => r,
None => {
return Ok(MoveResult {
new_path: new_path_str,
updated_links: 0,
moved: true,
})
}
};
// Determine the replacement: if path-style wikilinks were used, update to new path.
// Title-style wikilinks [[My Note]] stay the same (title hasn't changed).
let files = collect_md_files(vault, &new_file);
let updated_links = files
.iter()
.filter(|path| {
let file_content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
if !re.is_match(&file_content) {
return false;
}
// Replace path-based wikilinks (old_path_stem → new_path_stem)
// and keep title-based wikilinks as-is.
let replaced = re.replace_all(&file_content, |caps: &regex::Captures| {
let full_match = caps.get(0).map(|m| m.as_str()).unwrap_or("");
let pipe = caps.get(1);
// If the match used the path stem, replace with new path stem
if full_match.contains(old_path_stem) {
match pipe {
Some(p) => format!("[[{}{}]]", new_path_stem, p.as_str()),
None => format!("[[{}]]", new_path_stem),
}
} else {
// Title-based link — keep as-is (title hasn't changed)
full_match.to_string()
}
});
if replaced != file_content {
fs::write(path, replaced.as_ref()).is_ok()
} else {
false
}
})
.count();
Ok(MoveResult {
new_path: new_path_str,
updated_links,
moved: true,
})
}
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
pub fn rename_note(
vault_path: &str,
@@ -471,4 +648,223 @@ mod tests {
assert!(content.contains("title: Renamed Note"));
assert!(content.contains("# Renamed Note"));
}
// --- move_note_to_type_folder tests ---
#[test]
fn test_type_to_folder_slug_known_types() {
assert_eq!(type_to_folder_slug("Person"), "person");
assert_eq!(type_to_folder_slug("Project"), "project");
assert_eq!(type_to_folder_slug("Quarter"), "quarter");
assert_eq!(type_to_folder_slug("Note"), "note");
}
#[test]
fn test_type_to_folder_slug_unknown_types() {
assert_eq!(type_to_folder_slug("Key Result"), "key-result");
assert_eq!(type_to_folder_slug("My Custom Type"), "my-custom-type");
}
#[test]
fn test_move_note_basic() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\ntype: Quarter\n---\n# Weekly Review\n\nContent here.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
assert!(result.new_path.contains("/quarter/weekly-review.md"));
assert!(!old_path.exists());
assert!(Path::new(&result.new_path).exists());
}
#[test]
fn test_move_note_already_in_correct_folder() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"quarter/weekly-review.md",
"---\ntype: Quarter\n---\n# Weekly Review\n",
);
let path = vault.join("quarter/weekly-review.md");
let result =
move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "Quarter")
.unwrap();
assert!(!result.moved);
assert_eq!(result.new_path, path.to_str().unwrap());
assert_eq!(result.updated_links, 0);
}
#[test]
fn test_move_note_creates_target_folder() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/my-note.md",
"---\ntype: Quarter\n---\n# My Note\n",
);
let dest_dir = vault.join("quarter");
assert!(!dest_dir.exists());
let old_path = vault.join("note/my-note.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
assert!(dest_dir.exists());
assert!(Path::new(&result.new_path).exists());
}
#[test]
fn test_move_note_filename_collision() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/my-note.md",
"---\ntype: Quarter\n---\n# My Note\n",
);
create_test_file(
vault,
"quarter/my-note.md",
"---\ntype: Quarter\n---\n# Existing Note\n",
);
let old_path = vault.join("note/my-note.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
assert!(result.new_path.contains("/quarter/my-note-2.md"));
assert!(!old_path.exists());
assert!(Path::new(&result.new_path).exists());
// Original file should still exist
assert!(vault.join("quarter/my-note.md").exists());
}
#[test]
fn test_move_note_updates_path_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\ntype: Quarter\n---\n# Weekly Review\n\nContent.\n",
);
create_test_file(
vault,
"project/my-project.md",
"---\ntype: Project\n---\n# My Project\n\nSee [[note/weekly-review]] for details.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
assert_eq!(result.updated_links, 1);
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
assert!(project_content.contains("[[quarter/weekly-review]]"));
assert!(!project_content.contains("[[note/weekly-review]]"));
}
#[test]
fn test_move_note_preserves_title_wikilinks() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(
vault,
"note/weekly-review.md",
"---\ntype: Quarter\n---\n# Weekly Review\n",
);
create_test_file(
vault,
"note/other.md",
"---\ntype: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
assert!(result.moved);
// Title-based wikilinks should be unchanged
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
assert!(other_content.contains("[[Weekly Review]]"));
}
#[test]
fn test_move_note_empty_type_error() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/test.md", "# Test\n");
let path = vault.join("note/test.md");
let result = move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "");
assert!(result.is_err());
}
#[test]
fn test_move_note_nonexistent_file_error() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
vault.join("note/nope.md").to_str().unwrap(),
"Quarter",
);
assert!(result.is_err());
}
#[test]
fn test_move_note_preserves_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
let original = "---\ntype: Quarter\ntitle: My Note\n---\n# My Note\n\nImportant content.\n";
create_test_file(vault, "note/my-note.md", original);
let old_path = vault.join("note/my-note.md");
let result = move_note_to_type_folder(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Quarter",
)
.unwrap();
let content = fs::read_to_string(&result.new_path).unwrap();
assert_eq!(content, original);
}
}

View File

@@ -57,6 +57,37 @@ pub fn delete_note(path: &str) -> Result<String, String> {
Ok(path.to_string())
}
/// Check whether a file's frontmatter marks it as trashed.
/// Returns `true` if `Trashed: true` or `Trashed at` is present.
pub fn is_file_trashed(path: &Path) -> bool {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
let matter = Matter::<YAML>::new();
let parsed = matter.parse(&content);
// Check for "Trashed at" field — its presence implies trashed
if extract_trashed_at_string(&parsed.data).is_some() {
return true;
}
// Check for "Trashed: true"
if let Some(gray_matter::Pod::Hash(ref map)) = parsed.data {
if let Some(pod) = map.get("Trashed").or_else(|| map.get("trashed")) {
return match pod {
gray_matter::Pod::Boolean(b) => *b,
gray_matter::Pod::String(s) => {
matches!(s.to_ascii_lowercase().as_str(), "yes" | "true")
}
_ => false,
};
}
}
false
}
/// Scan all markdown files in the vault and delete those where
/// `Trashed at` frontmatter is more than 30 days ago.
/// Returns the list of deleted file paths.
@@ -155,7 +186,7 @@ mod tests {
create_test_file(
dir.path(),
"normal.md",
"---\nIs A: Note\n---\n# Normal Note\n",
"---\ntype: Note\n---\n# Normal Note\n",
);
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
@@ -248,4 +279,67 @@ mod tests {
assert_eq!(deleted.len(), 1);
assert!(deleted[0].contains("old.md"));
}
#[test]
fn test_is_file_trashed_with_trashed_true() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"trashed.md",
"---\nTrashed: true\n---\n# Gone\n",
);
assert!(is_file_trashed(&dir.path().join("trashed.md")));
}
#[test]
fn test_is_file_trashed_with_trashed_at() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"trashed.md",
"---\nTrashed at: \"2026-01-01\"\n---\n# Gone\n",
);
assert!(is_file_trashed(&dir.path().join("trashed.md")));
}
#[test]
fn test_is_file_trashed_with_trashed_yes() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "trashed.md", "---\nTrashed: Yes\n---\n# Gone\n");
assert!(is_file_trashed(&dir.path().join("trashed.md")));
}
#[test]
fn test_is_file_trashed_normal_note() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
assert!(!is_file_trashed(&dir.path().join("normal.md")));
}
#[test]
fn test_is_file_trashed_archived_not_trashed() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"archived.md",
"---\nArchived: true\n---\n# Archived\n",
);
assert!(!is_file_trashed(&dir.path().join("archived.md")));
}
#[test]
fn test_is_file_trashed_nonexistent_file() {
assert!(!is_file_trashed(Path::new("/nonexistent/path.md")));
}
#[test]
fn test_is_file_trashed_with_trashed_false() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"active.md",
"---\nTrashed: false\n---\n# Active\n",
);
assert!(!is_file_trashed(&dir.path().join("active.md")));
}
}

View File

@@ -54,7 +54,7 @@
"endpoints": [
"https://refactoringhq.github.io/laputa-app/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFQzlGQ0RFM0E1MTIzNDkKUldSSkkxRTYzdnpKVG13M0Zwd3M1RzErbWhJeEhBQUQyaG90bHBtMkNzMm1MNERZRlpXSGFRMTUK"
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE4NkQ5MDI3REVCRkFGNUMKUldSY3I3L2VKNUJ0cU5JRlRZZlp3NGhnU3ZwbkVKeGVvREpmb2sxRVJndHFpVFZPNlArbEE5R1IK"
}
}
}

View File

@@ -46,6 +46,7 @@ import type { SidebarSelection, VaultEntry } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries } from './utils/noteListHelpers'
import { openLocalFile } from './utils/url'
import { flushEditorContent } from './utils/autoSave'
import './App.css'
// Type declarations for mock content storage and test overrides
@@ -64,13 +65,21 @@ function useBulkActions(
setToastMessage: (msg: string | null) => void,
) {
const handleBulkArchive = useCallback(async (paths: string[]) => {
for (const path of paths) await entryActions.handleArchiveNote(path)
setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} archived`)
let ok = 0
for (const path of paths) {
try { await entryActions.handleArchiveNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
}, [entryActions, setToastMessage])
const handleBulkTrash = useCallback(async (paths: string[]) => {
for (const path of paths) await entryActions.handleTrashNote(path)
setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} moved to trash`)
let ok = 0
for (const path of paths) {
try { await entryActions.handleTrashNote(path); ok++ }
catch { /* error toast already shown by flushBeforeAction */ }
}
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
}, [entryActions, setToastMessage])
return { handleBulkArchive, handleBulkTrash }
@@ -109,7 +118,7 @@ function App() {
const vault = useVaultLoader(resolvedPath)
useVaultConfig(resolvedPath)
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent)
const themeManager = useThemeManager(resolvedPath, vault.entries)
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
@@ -173,7 +182,7 @@ function App() {
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
// Keep tab entries in sync with vault entries so banners (trash/archive)
// and read-only state react immediately without reopening the note.
@@ -298,12 +307,34 @@ function App() {
}, [vault, triggerIncrementalIndex])
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
updateEntry: vault.updateEntry,
setTabs: notes.setTabs, setToastMessage, onAfterSave,
onNotePersisted: vault.clearUnsaved,
})
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
// Refs for stable closure in flushBeforeAction (avoids re-creating on every tab/content change)
const tabsRef = useRef(notes.tabs)
tabsRef.current = notes.tabs // eslint-disable-line react-hooks/refs -- ref sync pattern
const unsavedPathsRef = useRef(vault.unsavedPaths)
unsavedPathsRef.current = vault.unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern
/** Auto-save unsaved editor content before a destructive action (trash/archive). */
const { clearUnsaved: vaultClearUnsaved } = vault
const flushBeforeAction = useCallback(async (path: string) => {
try {
await flushEditorContent(path, {
savePendingForPath,
getTabContent: (p) => tabsRef.current.find(t => t.entry.path === p)?.content,
isUnsaved: (p) => unsavedPathsRef.current.has(p),
onSaved: (p) => { vaultClearUnsaved(p) },
})
} catch (err) {
setToastMessage(`Auto-save failed: ${err}`)
throw err
}
}, [savePendingForPath, vaultClearUnsaved, setToastMessage])
// Wire conflict file opener now that notes is available
useEffect(() => {
openConflictFileRef.current = (relativePath: string) => {
@@ -314,7 +345,7 @@ function App() {
notes.handleSelectNote(entry)
dialogs.closeConflictResolver()
} else {
// Non-note file (e.g. .laputa-cache.json, settings.json) —
// Non-note file (e.g. settings.json) —
// open with system default app so the user can inspect/edit it
openLocalFile(fullPath)
}
@@ -338,6 +369,7 @@ function App() {
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
createTypeEntry: notes.createTypeEntrySilent,
onFrontmatterPersisted: vault.loadModifiedFiles,
onBeforeAction: flushBeforeAction,
})
const handleDeleteNote = useCallback(async (path: string) => {
@@ -432,7 +464,7 @@ function App() {
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
entries: vault.entries, allContent: vault.allContent,
entries: vault.entries,
modifiedCount: vault.modifiedFiles.length,
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
selection,
@@ -485,6 +517,7 @@ function App() {
mcpStatus,
onInstallMcp: installMcp,
onReindexVault: indexing.triggerFullReindex,
onReloadVault: vault.reloadVault,
onRepairVault: handleRepairVault,
})
@@ -549,7 +582,7 @@ function App() {
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<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} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -574,7 +607,6 @@ function App() {
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}
allContent={vault.allContent}
gitHistory={gitHistory}
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
onDeleteProperty={notes.handleDeleteProperty}

View File

@@ -15,7 +15,6 @@ import { MarkdownContent } from './MarkdownContent'
interface AIChatPanelProps {
entry: VaultEntry | null
allContent: Record<string, string>
entries?: VaultEntry[]
onClose: () => void
onNavigateWikilink?: (target: string) => void
@@ -177,17 +176,17 @@ function useContextNotes(entry: VaultEntry | null) {
// --- Main component ---
export function AIChatPanel({ entry, allContent, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
export function AIChatPanel({ entry, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
const [input, setInput] = useState('')
const [showSearch, setShowSearch] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const ctx = useContextNotes(entry)
const chat = useAIChat(allContent, ctx.contextNotes)
const chat = useAIChat(ctx.contextNotes)
const contextInfo = useMemo(
() => buildSystemPrompt(ctx.contextNotes, allContent),
[ctx.contextNotes, allContent],
() => buildSystemPrompt(ctx.contextNotes),
[ctx.contextNotes],
)
useEffect(() => {

View File

@@ -81,7 +81,7 @@ describe('AiPanel', () => {
it('renders contextual empty state when active entry is provided', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy()
})
@@ -89,7 +89,7 @@ describe('AiPanel', () => {
it('shows context bar with active entry title', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
expect(screen.getByTestId('context-bar')).toBeTruthy()
expect(screen.getByText('My Note')).toBeTruthy()
@@ -102,8 +102,7 @@ describe('AiPanel', () => {
<AiPanel
onClose={vi.fn()} vaultPath="/tmp/vault"
activeEntry={entry} entries={[entry, linked]}
allContent={{}}
/>
/>
)
expect(screen.getByText('+ 1 linked')).toBeTruthy()
})
@@ -129,7 +128,7 @@ describe('AiPanel', () => {
it('shows contextual placeholder when active entry exists', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask about this note...')

View File

@@ -16,8 +16,9 @@ interface AiPanelProps {
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[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
@@ -110,7 +111,7 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
)
}
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
@@ -122,17 +123,17 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
}, [activeEntry, entries])
const contextPrompt = useMemo(() => {
if (!activeEntry || !allContent || !entries) return undefined
if (!activeEntry || !entries) return undefined
return buildContextSnapshot({
activeEntry,
allContent,
activeNoteContent: activeNoteContent ?? undefined,
openTabs,
noteList,
noteListFilter,
entries,
references: pendingRefs.length > 0 ? pendingRefs : undefined,
})
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
}, [activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,

View File

@@ -130,7 +130,43 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('Luca')).toBeInTheDocument()
})
it('skips aliases and relationship keys', () => {
it('renders capitalized Owner with plain text value in Properties panel', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Owner: 'Luca' }}
/>
)
expect(screen.getByText('Owner')).toBeInTheDocument()
expect(screen.getByText('Luca')).toBeInTheDocument()
})
it('hides Owner with wikilink value from Properties panel', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Owner: '[[person/luca]]' }}
/>
)
// Owner with wikilink goes to RelationshipsPanel, not Properties
expect(screen.queryByText('Owner')).not.toBeInTheDocument()
})
it('renders notion_id as a visible property', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ notion_id: 'abc-123-def' }}
/>
)
expect(screen.getByText('notion_id')).toBeInTheDocument()
expect(screen.getByText('abc-123-def')).toBeInTheDocument()
})
it('skips aliases and fields with wikilink values', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -138,12 +174,37 @@ describe('DynamicPropertiesPanel', () => {
frontmatter={{ aliases: ['AL'], 'Belongs to': '[[Something]]', cadence: 'Monthly' }}
/>
)
// aliases and "Belongs to" should be skipped
// aliases skipped (in SKIP_KEYS); 'Belongs to' skipped (has wikilinks)
expect(screen.queryByText('aliases')).not.toBeInTheDocument()
expect(screen.queryByText('Belongs to')).not.toBeInTheDocument()
expect(screen.getByText('cadence')).toBeInTheDocument()
})
it('shows former relationship key with plain text value in Properties', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ 'Belongs to': 'some-team', cadence: 'Monthly' }}
/>
)
// 'Belongs to' has a plain text value, not a wikilink — should render as property
expect(screen.getByText('Belongs to')).toBeInTheDocument()
expect(screen.getByText('some-team')).toBeInTheDocument()
})
it('hides custom field with wikilink value from Properties', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Mentor: '[[person/luca]]' }}
/>
)
// Mentor contains a wikilink → shown in Relationships, not Properties
expect(screen.queryByText('Mentor')).not.toBeInTheDocument()
})
it('skips is_a, Is A, and type keys (shown via TypeRow instead)', () => {
render(
<DynamicPropertiesPanel

View File

@@ -27,12 +27,6 @@ import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
import { ColorEditableValue } from './ColorInput'
// Keys that are relationships (contain wikilinks)
export const RELATIONSHIP_KEYS = new Set([
'Belongs to', 'Related to', 'Events', 'Has Data', 'Owner',
'Advances', 'Parent', 'Children', 'Has', 'Notes',
])
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function containsWikilinks(value: FrontmatterValue): boolean {
if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value)
@@ -658,14 +652,13 @@ function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: n
}
export function DynamicPropertiesPanel({
entry, content, frontmatter, entries, allContent,
entry, content, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
}: {
entry: VaultEntry
content: string | null
frontmatter: ParsedFrontmatter
entries?: VaultEntry[]
allContent?: Record<string, string>
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
@@ -675,7 +668,7 @@ export function DynamicPropertiesPanel({
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty })
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
const wordCount = countWords(content ?? '')

View File

@@ -105,7 +105,6 @@ const defaultProps = {
onInspectorResize: vi.fn(),
inspectorEntry: null as VaultEntry | null,
inspectorContent: null as string | null,
allContent: {} as Record<string, string>,
gitHistory: [],
onCreateNote: vi.fn(),
}

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback, memo, useMemo } from 'react'
import { useRef, useEffect, useCallback, memo } from 'react'
import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap'
import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync'
import { useCreateBlockNote } from '@blocknote/react'
@@ -15,7 +15,6 @@ 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'
@@ -42,7 +41,6 @@ interface EditorProps {
onInspectorResize: (delta: number) => void
inspectorEntry: VaultEntry | null
inspectorContent: string | null
allContent: Record<string, string>
gitHistory: GitCommit[]
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onDeleteProperty?: (path: string, key: string) => Promise<void>
@@ -121,7 +119,7 @@ export const Editor = memo(function Editor({
tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink,
onLoadDiff, onLoadDiffAtCommit, getNoteStatus, onCreateNote,
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
inspectorEntry, inspectorContent, allContent, gitHistory,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
@@ -173,11 +171,6 @@ 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'))
@@ -240,7 +233,6 @@ export const Editor = memo(function Editor({
inspectorEntry={inspectorEntry}
inspectorContent={inspectorContent}
entries={entries}
allContent={enrichedAllContent}
gitHistory={gitHistory}
vaultPath={vaultPath ?? ''}
openTabs={tabs.map(t => t.entry)}

View File

@@ -10,7 +10,6 @@ interface EditorRightPanelProps {
inspectorEntry: VaultEntry | null
inspectorContent: string | null
entries: VaultEntry[]
allContent: Record<string, string>
gitHistory: GitCommit[]
vaultPath: string
openTabs?: VaultEntry[]
@@ -31,7 +30,7 @@ interface EditorRightPanelProps {
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath, openTabs,
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, openTabs,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
@@ -51,8 +50,8 @@ export function EditorRightPanel({
onVaultChanged={onVaultChanged}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
activeNoteContent={inspectorContent}
entries={entries}
allContent={allContent}
openTabs={openTabs}
noteList={noteList}
noteListFilter={noteListFilter}
@@ -74,7 +73,6 @@ export function EditorRightPanel({
entry={inspectorEntry}
content={inspectorContent}
entries={entries}
allContent={allContent}
gitHistory={gitHistory}
onNavigate={onNavigateWikilink}
onViewCommitDiff={onViewCommitDiff}

View File

@@ -543,7 +543,7 @@ Status: Active
<Inspector
{...defaultProps}
entry={typeEntry}
content="---\nIs A: Type\n---\n# Responsibility\n"
content="---\ntype: Type\n---\n# Responsibility\n"
entries={[typeEntry, essayEntry]}
/>

View File

@@ -5,9 +5,8 @@ import { cn } from '@/lib/utils'
import { SlidersHorizontal, X } from '@phosphor-icons/react'
import { parseFrontmatter } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { wikilinkTarget } from '../utils/wikilink'
import { extractBacklinkContext } from '../utils/wikilinks'
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
export type FrontmatterValue = string | number | boolean | string[] | null
@@ -18,7 +17,6 @@ interface InspectorProps {
entry: VaultEntry | null
content: string | null
entries: VaultEntry[]
allContent?: Record<string, string>
gitHistory: GitCommit[]
onNavigate: (target: string) => void
onViewCommitDiff?: (commitHash: string) => void
@@ -31,7 +29,6 @@ function useBacklinks(
entry: VaultEntry | null,
entries: VaultEntry[],
referencedBy: ReferencedByItem[],
allContent?: Record<string, string>,
): BacklinkItem[] {
return useMemo(() => {
if (!entry) return []
@@ -53,11 +50,9 @@ function useBacklinks(
})
.map((e) => ({
entry: e,
context: allContent?.[e.path]
? extractBacklinkContext(allContent[e.path], matchTargets)
: null,
context: null,
}))
}, [entry, entries, referencedBy, allContent])
}, [entry, entries, referencedBy])
}
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
@@ -118,11 +113,11 @@ function EmptyInspector() {
}
export function Inspector({
collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate,
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
}: InspectorProps) {
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy, allContent)
const backlinks = useBacklinks(entry, entries, referencedBy)
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
const typeEntryMap = useMemo(() => {
const map: Record<string, VaultEntry> = {}
@@ -151,7 +146,7 @@ export function Inspector({
<>
<DynamicPropertiesPanel
entry={entry} content={content} frontmatter={frontmatter}
entries={entries} allContent={allContent}
entries={entries}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
@@ -163,6 +158,7 @@ export function Inspector({
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<BacklinksPanel backlinks={backlinks} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import type { ReferencedByItem } from './InspectorPanels'
import type { VaultEntry, GitCommit } from '../types'
@@ -568,3 +568,112 @@ describe('GitHistoryPanel', () => {
expect(screen.getByText('10d ago')).toBeInTheDocument()
})
})
describe('InstancesPanel', () => {
const onNavigate = vi.fn()
const quarterType = makeEntry({
path: '/vault/type/quarter.md', filename: 'quarter.md', title: 'Quarter',
isA: 'Type', color: 'blue', icon: 'calendar',
})
const typeEntryMap: Record<string, VaultEntry> = { Quarter: quarterType }
beforeEach(() => {
vi.clearAllMocks()
})
it('renders nothing when entry is not a Type', () => {
const entry = makeEntry({ title: 'Random Note', isA: 'Note' })
const { container } = render(
<InstancesPanel entry={entry} entries={[]} typeEntryMap={{}} onNavigate={onNavigate} />
)
expect(container.innerHTML).toBe('')
})
it('renders nothing when Type has zero instances', () => {
const { container } = render(
<InstancesPanel entry={quarterType} entries={[]} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(container.innerHTML).toBe('')
})
it('renders instances of a Type sorted by modifiedAt descending', () => {
const instances = [
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
makeEntry({ path: '/vault/quarter/q2.md', title: 'Q2 2026', isA: 'Quarter', modifiedAt: 3000 }),
makeEntry({ path: '/vault/quarter/q3.md', title: 'Q3 2026', isA: 'Quarter', modifiedAt: 2000 }),
]
render(
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(screen.getByText('Instances (3)')).toBeInTheDocument()
const buttons = screen.getAllByRole('button').filter(b => ['Q1 2026', 'Q2 2026', 'Q3 2026'].includes(b.textContent?.replace(/\s*\(.*\)/, '') ?? ''))
// Q2 (3000) should come before Q3 (2000) before Q1 (1000)
expect(buttons[0].textContent).toContain('Q2 2026')
expect(buttons[1].textContent).toContain('Q3 2026')
expect(buttons[2].textContent).toContain('Q1 2026')
})
it('excludes trashed instances', () => {
const instances = [
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 2000 }),
makeEntry({ path: '/vault/quarter/q2.md', title: 'Q2 Trashed', isA: 'Quarter', trashed: true, modifiedAt: 3000 }),
]
render(
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(screen.getByText('Q1 2026')).toBeInTheDocument()
expect(screen.queryByText('Q2 Trashed')).not.toBeInTheDocument()
})
it('dims archived instances', () => {
const instances = [
makeEntry({ path: '/vault/quarter/old.md', title: 'Q4 2024', isA: 'Quarter', archived: true, modifiedAt: 1000 }),
]
render(
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(screen.getByTitle('Archived')).toBeInTheDocument()
})
it('navigates when clicking an instance', () => {
const instances = [
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
]
render(
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
fireEvent.click(screen.getByText('Q1 2026'))
expect(onNavigate).toHaveBeenCalledWith('Q1 2026')
})
it('caps display at 50 instances and shows count badge', () => {
const instances = Array.from({ length: 80 }, (_, i) =>
makeEntry({
path: `/vault/quarter/q${i}.md`,
title: `Instance ${i}`,
isA: 'Quarter',
modifiedAt: 80 - i,
})
)
render(
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(screen.getByText('Instances (80)')).toBeInTheDocument()
// Only 50 link buttons rendered
const allButtons = screen.getAllByRole('button')
const instanceButtons = allButtons.filter(b => b.textContent?.startsWith('Instance'))
expect(instanceButtons.length).toBe(50)
expect(screen.getByText('showing 50 of 80')).toBeInTheDocument()
})
it('does not show Instances section for non-Type note even if title matches a type name', () => {
const notAType = makeEntry({ title: 'Quarter', isA: 'Project' })
const instances = [
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
]
const { container } = render(
<InstancesPanel entry={notAType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
)
expect(container.innerHTML).toBe('')
})
})

View File

@@ -4,3 +4,4 @@ export type { BacklinkItem } from './inspector/BacklinksPanel'
export { ReferencedByPanel } from './inspector/ReferencedByPanel'
export type { ReferencedByItem } from './inspector/ReferencedByPanel'
export { GitHistoryPanel } from './inspector/GitHistoryPanel'
export { InstancesPanel } from './inspector/InstancesPanel'

View File

@@ -153,38 +153,38 @@ const mockEntries: VaultEntry[] = [
describe('NoteList', () => {
it('shows empty state when no entries', () => {
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('No notes found')).toBeInTheDocument()
})
it('renders all entries with All Notes filter', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
})
it('filters by People (section group)', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('filters by Events (section group)', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('filters by section group type', () => {
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('shows entity pinned at top with grouped children', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Entity title appears in header and pinned card
expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1)
@@ -199,7 +199,7 @@ describe('NoteList', () => {
it('filters by topic (relatedTo references)', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Build Laputa App has relatedTo: [[topic/software-development]]
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
@@ -207,7 +207,7 @@ describe('NoteList', () => {
})
it('shows search input when search icon is clicked', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Search is hidden by default
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
// Click search icon to show it
@@ -216,7 +216,7 @@ describe('NoteList', () => {
})
it('filters by search query (case-insensitive substring)', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Open search
fireEvent.click(screen.getByTitle('Search notes'))
const input = screen.getByPlaceholderText('Search notes...')
@@ -231,31 +231,31 @@ describe('NoteList', () => {
{ ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
{ ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
]
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
const titles = screen.getAllByText(/Oldest|Newest|Middle/)
const titleTexts = titles.map((el) => el.textContent)
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
})
it('does not render type badge or status on note items', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Type badges like "Project", "Note" etc. should not appear as separate badge elements
// The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge
expect(screen.queryByText('Active')).not.toBeInTheDocument()
})
it('header shows search and plus icons instead of count badge', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByTitle('Search notes')).toBeInTheDocument()
expect(screen.getByTitle('Create new note')).toBeInTheDocument()
})
it('context view shows backlinks from allContent', () => {
const allContent = {
[mockEntries[2].path]: 'Met with [[project/26q1-laputa-app]] team.',
}
it('context view shows backlinks from outgoingLinks', () => {
const entriesWithBacklink = mockEntries.map(e =>
e.path === mockEntries[2].path ? { ...e, outgoingLinks: ['Build Laputa App'] } : e
)
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={allContent} onCreateNote={vi.fn()} />
<NoteList entries={entriesWithBacklink} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Backlinks')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
@@ -263,7 +263,7 @@ describe('NoteList', () => {
it('context view collapses and expands groups', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Children group is expanded by default
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
@@ -278,7 +278,7 @@ describe('NoteList', () => {
it('context view shows prominent card with snippet subtitle', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Snippet text appears in the prominent card
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
@@ -292,21 +292,21 @@ describe('NoteList click behavior', () => {
})
it('regular click calls onReplaceActiveTab (opens in current tab)', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'))
expect(noopReplace).toHaveBeenCalledWith(mockEntries[0])
expect(noopSelect).not.toHaveBeenCalled()
})
it('Cmd+Click calls onSelectNote (opens in new tab)', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
expect(noopReplace).not.toHaveBeenCalled()
})
it('Ctrl+Click calls onSelectNote (Windows/Linux)', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
expect(noopReplace).not.toHaveBeenCalled()
@@ -314,7 +314,7 @@ describe('NoteList click behavior', () => {
it('Cmd+Click on entity pinned card calls onSelectNote', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
const titles = screen.getAllByText('Build Laputa App')
fireEvent.click(titles[titles.length - 1], { metaKey: true })
@@ -324,7 +324,7 @@ describe('NoteList click behavior', () => {
it('regular click on entity pinned card calls onReplaceActiveTab', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
const titles = screen.getAllByText('Build Laputa App')
@@ -335,7 +335,7 @@ describe('NoteList click behavior', () => {
it('click on child note in entity view calls onReplaceActiveTab', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
expect(noopReplace).toHaveBeenCalledWith(mockEntries[1])
@@ -489,21 +489,21 @@ describe('NoteList sort controls', () => {
it('shows sort button in note list header for flat view', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
})
it('shows sort dropdown per relationship subsection in entity view', () => {
render(
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
})
it('opens sort menu on click and shows all options', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
@@ -520,7 +520,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default sort: by modified (Zebra first)
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
@@ -537,7 +537,7 @@ describe('NoteList sort controls', () => {
it('closes sort menu after selecting an option', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
@@ -547,7 +547,7 @@ describe('NoteList sort controls', () => {
it('shows direction arrows in sort dropdown menu', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
// Each option should have asc and desc direction buttons
@@ -564,7 +564,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default sort: modified descending (Zebra first at 3000)
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
@@ -585,7 +585,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Select title sort with desc direction
fireEvent.click(screen.getByTestId('sort-button-__list__'))
@@ -598,7 +598,7 @@ describe('NoteList sort controls', () => {
it('shows direction icon on the sort button that reflects current direction', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default: modified desc → should have ArrowDown icon
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
@@ -635,7 +635,7 @@ describe('NoteList sort controls', () => {
const entries = [parent, child1, child2]
render(
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default sort: by modified — Zebra Note (3000) before Alpha Note (1000)
@@ -657,7 +657,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-separator')).toBeInTheDocument()
@@ -668,7 +668,7 @@ describe('NoteList sort controls', () => {
it('omits separator when no custom properties exist', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument()
@@ -681,7 +681,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default: modified desc → A, B, C
let titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent)
@@ -703,7 +703,7 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-option-property:Priority'))
@@ -821,7 +821,7 @@ describe('NoteList — status indicators', () => {
it('shows modified indicator dot for modified notes', () => {
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'modified' as const : 'clean' as const
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
)
const indicators = screen.getAllByTestId('modified-indicator')
expect(indicators).toHaveLength(1)
@@ -832,7 +832,7 @@ describe('NoteList — status indicators', () => {
it('does not show indicator when all notes are clean', () => {
const getNoteStatus = () => 'clean' as const
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
@@ -842,14 +842,14 @@ describe('NoteList — status indicators', () => {
const modifiedPaths = new Set([mockEntries[0].path, mockEntries[1].path])
const getNoteStatus = (path: string) => modifiedPaths.has(path) ? 'modified' as const : 'clean' as const
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
)
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
})
it('does not show indicator when getNoteStatus prop is undefined', () => {
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
@@ -858,7 +858,7 @@ describe('NoteList — status indicators', () => {
it('shows green new indicator for new notes', () => {
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'new' as const : 'clean' as const
render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
)
expect(screen.getAllByTestId('new-indicator')).toHaveLength(1)
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
@@ -869,31 +869,31 @@ describe('NoteList — trash view', () => {
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }
it('shows "Trash" header when trash filter is active', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Trash')).toBeInTheDocument()
})
it('shows only trashed entries in trash view', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Old Draft Notes')).toBeInTheDocument()
expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument()
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
})
it('shows TRASHED badge on trashed entries', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
const badges = screen.getAllByText('TRASHED')
expect(badges.length).toBeGreaterThanOrEqual(1)
})
it('shows 30-day warning banner when expired notes exist', () => {
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument()
expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument()
})
it('shows "Trash is empty" when no trashed entries', () => {
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.getByText('Trash is empty')).toBeInTheDocument()
})
})
@@ -933,7 +933,7 @@ describe('NoteList — virtual list with large datasets', () => {
it('renders 9000 entries without crashing', { timeout: 30000 }, () => {
const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i))
const { container } = render(
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Virtuoso mock renders all items; the real component only renders visible ones
expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument()
@@ -942,7 +942,7 @@ describe('NoteList — virtual list with large datasets', () => {
it('renders items from a large dataset via Virtuoso', () => {
const largeDataset = Array.from({ length: 500 }, (_, i) => makeEntry(i))
render(
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Note 0')).toBeInTheDocument()
expect(screen.getByText('Note 499')).toBeInTheDocument()
@@ -955,7 +955,7 @@ describe('NoteList — virtual list with large datasets', () => {
makeEntry(999, { title: 'Beta Strategy' }),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Search notes'))
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
@@ -971,7 +971,7 @@ describe('NoteList — virtual list with large datasets', () => {
...Array.from({ length: 100 }, (_, i) => makeEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })),
]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default sort is modified desc — Alpha (3000) should come first
const firstTitle = screen.getAllByText(/^Alpha$|^Zebra$/)[0]
@@ -984,7 +984,7 @@ describe('NoteList — virtual list with large datasets', () => {
...Array.from({ length: 200 }, (_, i) => makeEntry(100 + i, { isA: 'Note', title: `Note ${i}` })),
]
render(
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Project 0')).toBeInTheDocument()
expect(screen.queryByText('Note 0')).not.toBeInTheDocument()
@@ -994,7 +994,7 @@ describe('NoteList — virtual list with large datasets', () => {
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
const selected = entries[5]
render(
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Note 5')).toBeInTheDocument()
})
@@ -1003,7 +1003,7 @@ describe('NoteList — virtual list with large datasets', () => {
noopReplace.mockClear()
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
render(
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByText('Note 50'))
expect(noopReplace).toHaveBeenCalledWith(entries[50])
@@ -1018,7 +1018,7 @@ describe('NoteList — virtual list with large datasets', () => {
it('shows only modified notes in changes view', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
@@ -1028,21 +1028,21 @@ describe('NoteList — virtual list with large datasets', () => {
it('shows header title "Changes"', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Changes')).toBeInTheDocument()
})
it('shows empty state when no modified files', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('No pending changes')).toBeInTheDocument()
})
it('updates list when modifiedFiles changes', () => {
const { rerender } = render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
@@ -1050,7 +1050,7 @@ describe('NoteList — virtual list with large datasets', () => {
// Simulate one file being committed (removed from modifiedFiles)
const fewerModified = [modifiedFiles[0]]
rerender(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
@@ -1061,7 +1061,7 @@ describe('NoteList — virtual list with large datasets', () => {
// The changes filter must use modifiedFiles for filtering even when getNoteStatus is present.
const getNoteStatus = (path: string) => modifiedFiles.some((f) => f.path === path) ? 'modified' as const : 'clean' as const
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
@@ -1079,7 +1079,7 @@ describe('NoteList — virtual list with large datasets', () => {
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
]
render(
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Even though absolute paths differ, entries should match via relative path suffix
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
@@ -1089,7 +1089,7 @@ describe('NoteList — virtual list with large datasets', () => {
it('shows error message when modifiedFilesError is set', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument()
expect(screen.getByText(/git status failed/)).toBeInTheDocument()
@@ -1101,7 +1101,7 @@ describe('NoteList — virtual list with large datasets', () => {
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
]
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
@@ -1119,7 +1119,7 @@ describe('NoteList — multi-select', () => {
})
it('Shift+Click selects a range of notes', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Regular click to set anchor
fireEvent.click(screen.getByText('Build Laputa App'))
// Shift+Click to select range
@@ -1130,7 +1130,7 @@ describe('NoteList — multi-select', () => {
})
it('regular click clears multi-select and opens note', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Select range via Shift+click
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
@@ -1142,7 +1142,7 @@ describe('NoteList — multi-select', () => {
})
it('Cmd+Click clears multi-select and opens in new tab', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
// Select range via Shift+click
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
@@ -1154,7 +1154,7 @@ describe('NoteList — multi-select', () => {
})
it('shows bulk action bar with correct count', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
@@ -1163,7 +1163,7 @@ describe('NoteList — multi-select', () => {
it('bulk archive calls onBulkArchive and clears selection', () => {
const onBulkArchive = vi.fn()
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByTestId('bulk-archive-btn'))
@@ -1173,7 +1173,7 @@ describe('NoteList — multi-select', () => {
it('bulk trash calls onBulkTrash and clears selection', () => {
const onBulkTrash = vi.fn()
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByTestId('bulk-trash-btn'))
@@ -1182,7 +1182,7 @@ describe('NoteList — multi-select', () => {
})
it('clear button on bulk action bar clears selection', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
@@ -1193,7 +1193,7 @@ describe('NoteList — multi-select', () => {
it('Cmd+E archives selected notes when multiselect is active', () => {
const onBulkArchive = vi.fn()
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
@@ -1204,7 +1204,7 @@ describe('NoteList — multi-select', () => {
it('Cmd+Backspace trashes selected notes when multiselect is active', () => {
const onBulkTrash = vi.fn()
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
@@ -1215,7 +1215,7 @@ describe('NoteList — multi-select', () => {
it('Cmd+Delete trashes selected notes when multiselect is active', () => {
const onBulkTrash = vi.fn()
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.keyDown(window, { key: 'Delete', metaKey: true })
@@ -1224,7 +1224,7 @@ describe('NoteList — multi-select', () => {
})
it('no bulk action bar when nothing is selected', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
})
@@ -1269,7 +1269,7 @@ describe('NoteList — type note filtering', () => {
it('does not show type note PinnedCard when browsing a sectionGroup', () => {
render(
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// The type note snippet should NOT be visible (PinnedCard was removed)
expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument()
@@ -1279,7 +1279,7 @@ describe('NoteList — type note filtering', () => {
it('shows clickable header title that navigates to type note', () => {
render(
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
const headerLink = screen.getByTestId('type-header-link')
expect(headerLink).toBeInTheDocument()
@@ -1291,7 +1291,7 @@ describe('NoteList — type note filtering', () => {
it('header is not clickable when not viewing a type section', () => {
render(
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument()
})
@@ -1300,7 +1300,7 @@ describe('NoteList — type note filtering', () => {
describe('NoteList — traffic light padding when sidebar collapsed', () => {
it('adds left padding to header when sidebarCollapsed is true', () => {
const { container } = render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} sidebarCollapsed={true} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={true} onCreateNote={vi.fn()} />
)
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('80px')
@@ -1308,7 +1308,7 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
it('does not add extra left padding when sidebarCollapsed is false', () => {
const { container } = render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} sidebarCollapsed={false} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={false} onCreateNote={vi.fn()} />
)
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')
@@ -1316,7 +1316,7 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
it('does not add extra left padding when sidebarCollapsed is not provided', () => {
const { container } = render(
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')

View File

@@ -25,7 +25,6 @@ interface NoteListProps {
entries: VaultEntry[]
selection: SidebarSelection
selectedNote: VaultEntry | null
allContent: Record<string, string>
modifiedFiles?: ModifiedFile[]
modifiedFilesError?: string | null
getNoteStatus?: (path: string) => NoteStatus
@@ -241,7 +240,7 @@ function toggleSetMember<T>(set: Set<T>, member: T): Set<T> {
// --- Data hooks ---
interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
entries: VaultEntry[]; selection: SidebarSelection
query: string; listSort: SortOption; listDirection: SortDirection
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
}
@@ -261,7 +260,7 @@ function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection,
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes])
}
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
@@ -274,9 +273,9 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
const groups = buildRelationshipGroups(selection.entry, entries, allContent)
const groups = buildRelationshipGroups(selection.entry, entries)
return filterGroupsByQuery(groups, query)
}, [isEntityView, selection, entries, allContent, query])
}, [isEntityView, selection, entries, query])
const expiredTrashCount = useMemo(
() => isTrashView ? countExpiredTrash(searched) : 0,
@@ -484,14 +483,14 @@ function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, getNot
return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
}
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry })
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const typeEntryMap = useTypeEntryMap(entries)
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null

View File

@@ -52,7 +52,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(
() => deduplicateByPath(entries.map(entry => ({
() => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',

View File

@@ -62,7 +62,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(
() => deduplicateByPath(entries.map(entry => ({
() => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',

View File

@@ -8,7 +8,7 @@ function makeThemeManager(overrides: Partial<ThemeManager> = {}): ThemeManager {
themes: [],
activeThemeId: '/vault/_themes/My Theme.md',
activeTheme: { id: '/vault/_themes/My Theme.md', name: 'My Theme', description: '', colors: {}, typography: {}, spacing: {} },
activeThemeContent: '---\nIs A: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
activeThemeContent: '---\ntype: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
isDark: false,
switchTheme: vi.fn(),
createTheme: vi.fn().mockResolvedValue(''),

View File

@@ -8,6 +8,7 @@ import { useState, useRef, useMemo, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { NoteReference } from '../utils/ai-context'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { bestSearchRank } from '../utils/fuzzyMatch'
const MAX_SUGGESTIONS = 20
const MIN_QUERY_LENGTH = 1
@@ -46,13 +47,16 @@ function matchEntries(
): SuggestionEntry[] {
if (query.length < MIN_QUERY_LENGTH) return []
const lower = query.toLowerCase()
const matches = entries.filter(e =>
!e.trashed && !e.archived && (
e.title.toLowerCase().includes(lower) ||
e.aliases.some(a => a.toLowerCase().includes(lower))
),
)
return matches.slice(0, MAX_SUGGESTIONS).map(e => {
const matches = entries
.filter(e =>
!e.trashed && !e.archived && (
e.title.toLowerCase().includes(lower) ||
e.aliases.some(a => a.toLowerCase().includes(lower))
),
)
.map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) }))
.sort((a, b) => a.rank - b.rank)
return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => {
const te = typeEntryMap[e.isA ?? '']
return {
title: e.title,

View File

@@ -0,0 +1,57 @@
import { useMemo } from 'react'
import type { VaultEntry } from '../../types'
import { getTypeColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { LinkButton } from './LinkButton'
import { entryStatusTitle } from './shared'
const MAX_DISPLAY = 50
export function InstancesPanel({ entry, entries, typeEntryMap, onNavigate }: {
entry: VaultEntry
entries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const instances = useMemo(() => {
if (entry.isA !== 'Type') return []
return entries
.filter((e) => e.isA === entry.title && !e.trashed)
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
}, [entry, entries])
if (instances.length === 0) return null
const displayed = instances.slice(0, MAX_DISPLAY)
const total = instances.length
return (
<div>
<span className="font-mono-overline mb-1 block text-muted-foreground">
Instances ({total})
</span>
<div className="flex flex-col gap-0.5">
{displayed.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton
key={e.path}
label={e.title}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
isTrashed={false}
onClick={() => onNavigate(e.title)}
title={entryStatusTitle(e)}
TypeIcon={getTypeIcon(e.isA, te?.icon)}
/>
)
})}
</div>
{total > MAX_DISPLAY && (
<span className="mt-1 block text-[11px] text-muted-foreground">
showing {MAX_DISPLAY} of {total}
</span>
)}
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { useMemo, useCallback, useState, useRef } from 'react'
import type { VaultEntry } from '../../types'
import { X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../../utils/frontmatter'
import { RELATIONSHIP_KEYS, containsWikilinks } from '../DynamicPropertiesPanel'
import { containsWikilinks } from '../DynamicPropertiesPanel'
import type { FrontmatterValue } from '../Inspector'
import { NoteSearchList } from '../NoteSearchList'
import { useNoteSearch } from '../../hooks/useNoteSearch'
@@ -123,7 +123,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string; refs: string[] }[] {
return Object.entries(frontmatter)
.filter(([key, value]) => key !== 'Type' && (RELATIONSHIP_KEYS.has(key) || containsWikilinks(value)))
.filter(([key, value]) => key !== 'Type' && containsWikilinks(value))
.map(([key, value]) => {
const refs: string[] = []
if (typeof value === 'string' && isWikilink(value)) refs.push(value)

View File

@@ -41,7 +41,6 @@ function renderNoteList(props: {
entries={props.entries}
selection={props.selection}
selectedNote={null}
allContent={{}}
onSelectNote={noop}
onReplaceActiveTab={noop}
onCreateNote={noop}

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { getDocumentZoom, adjustCoordsForZoom } from './zoomCursorFix'
function mockComputedZoom(value: string) {
const real = window.getComputedStyle.bind(window)
return vi.spyOn(window, 'getComputedStyle').mockImplementation((elt, pseudo) => {
const style = real(elt, pseudo)
if (elt === document.documentElement) {
return new Proxy(style, {
get(target, prop) {
if (prop === 'zoom') return value
const val = Reflect.get(target, prop)
return typeof val === 'function' ? val.bind(target) : val
},
})
}
return style
})
}
describe('getDocumentZoom', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('returns 1 when no zoom is set', () => {
expect(getDocumentZoom()).toBe(1)
})
it('returns the zoom factor when computed style reports a decimal', () => {
const spy = mockComputedZoom('1.5')
expect(getDocumentZoom()).toBe(1.5)
spy.mockRestore()
})
it('returns the zoom factor for sub-100% zoom', () => {
const spy = mockComputedZoom('0.8')
expect(getDocumentZoom()).toBe(0.8)
spy.mockRestore()
})
it('returns 1 for zoom: normal', () => {
const spy = mockComputedZoom('normal')
expect(getDocumentZoom()).toBe(1)
spy.mockRestore()
})
it('returns 1 for empty/missing zoom value', () => {
const spy = mockComputedZoom('')
expect(getDocumentZoom()).toBe(1)
spy.mockRestore()
})
})
describe('adjustCoordsForZoom', () => {
it('returns coords unchanged when zoom is 1', () => {
expect(adjustCoordsForZoom({ x: 200, y: 100 }, 1)).toEqual({ x: 200, y: 100 })
})
it('divides coords by zoom factor for zoom > 1', () => {
const result = adjustCoordsForZoom({ x: 300, y: 150 }, 1.5)
expect(result.x).toBe(200)
expect(result.y).toBe(100)
})
it('divides coords by zoom factor for zoom < 1', () => {
const result = adjustCoordsForZoom({ x: 160, y: 80 }, 0.8)
expect(result.x).toBe(200)
expect(result.y).toBe(100)
})
it('handles common zoom levels correctly', () => {
// 90% zoom
const at90 = adjustCoordsForZoom({ x: 90, y: 90 }, 0.9)
expect(at90.x).toBeCloseTo(100, 10)
expect(at90.y).toBeCloseTo(100, 10)
// 110% zoom
const at110 = adjustCoordsForZoom({ x: 110, y: 110 }, 1.1)
expect(at110.x).toBeCloseTo(100, 10)
expect(at110.y).toBeCloseTo(100, 10)
// 125% zoom
const at125 = adjustCoordsForZoom({ x: 125, y: 125 }, 1.25)
expect(at125.x).toBeCloseTo(100, 10)
expect(at125.y).toBeCloseTo(100, 10)
})
})

View File

@@ -0,0 +1,156 @@
import { EditorView, ViewPlugin } from '@codemirror/view'
/**
* Read the current CSS zoom factor from document.documentElement.
* Returns 1 when no zoom is applied or the value is unparseable.
*
* Checks getComputedStyle first (real browsers return the decimal value),
* then falls back to the inline style property (works in jsdom and test
* environments where getComputedStyle doesn't report zoom).
*/
export function getDocumentZoom(): number {
const computed = getComputedStyle(document.documentElement).zoom
if (computed && computed !== 'normal') {
const parsed = parseFloat(computed)
if (parsed > 0 && isFinite(parsed)) return parsed
}
const inline = document.documentElement.style.getPropertyValue('zoom')
if (inline && inline !== 'normal') {
let value = parseFloat(inline)
if (inline.endsWith('%')) value /= 100
if (value > 0 && isFinite(value)) return value
}
return 1
}
/**
* Convert viewport-space coordinates to CSS-space coordinates by
* dividing by the zoom factor. When CSS zoom is applied to the root
* element, mouse event clientX/clientY are in viewport space, but
* Range.getClientRects() (used by CodeMirror's posAtCoords) may return
* values in CSS space. Dividing by zoom aligns them.
*/
export function adjustCoordsForZoom(
coords: { x: number; y: number },
zoom: number,
): { x: number; y: number } {
if (zoom === 1) return coords
return { x: coords.x / zoom, y: coords.y / zoom }
}
/**
* Use the browser's native caretRangeFromPoint API to find the document
* position at viewport coordinates. This API correctly handles CSS zoom
* because it operates in the browser's own coordinate system.
*
* Returns null if the API is unavailable or the position is outside the
* editor's content area.
*/
function caretPosFromPoint(
view: EditorView,
x: number,
y: number,
): number | null {
if (typeof document.caretRangeFromPoint !== 'function') return null
const range = document.caretRangeFromPoint(x, y)
if (!range) return null
if (!view.contentDOM.contains(range.startContainer)) return null
try {
return view.posAtDOM(range.startContainer, range.startOffset)
} catch {
return null
}
}
type Coords = { x: number; y: number }
type PosAndSide = { pos: number; assoc: -1 | 1 }
/**
* CodeMirror extension that fixes cursor positioning at non-100% CSS zoom.
*
* When CSS `zoom` is applied to document.documentElement, CodeMirror's
* posAtCoords breaks because it compares mouse event coordinates (viewport
* space) against Range.getClientRects() values (which may be in CSS space
* under zoom). This extension overrides posAtCoords and posAndSideAtCoords
* on the EditorView instance with zoom-aware versions that:
*
* 1. Use document.caretRangeFromPoint() — the browser's native, zoom-aware
* coordinate-to-text API — to find the correct position.
* 2. Fall back to the original method with coordinates divided by the zoom
* factor if caretRangeFromPoint is unavailable or returns no result.
*/
export function zoomCursorFix() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFn = (...args: any[]) => any
return ViewPlugin.define((view) => {
const origPosAtCoords: AnyFn =
Object.getPrototypeOf(view).posAtCoords
const origPosAndSideAtCoords: AnyFn =
Object.getPrototypeOf(view).posAndSideAtCoords
function zoomPosAtCoords(
self: EditorView,
coords: Coords,
precise?: boolean,
): number | null {
const zoom = getDocumentZoom()
if (zoom === 1) return origPosAtCoords.call(self, coords, precise)
const pos = caretPosFromPoint(self, coords.x, coords.y)
if (pos !== null) return pos
const adjusted = adjustCoordsForZoom(coords, zoom)
return origPosAtCoords.call(self, adjusted, precise)
}
function zoomPosAndSideAtCoords(
self: EditorView,
coords: Coords,
precise?: boolean,
): PosAndSide | null {
const zoom = getDocumentZoom()
if (zoom === 1)
return origPosAndSideAtCoords.call(self, coords, precise)
const pos = caretPosFromPoint(self, coords.x, coords.y)
if (pos !== null) return { pos, assoc: 1 }
const adjusted = adjustCoordsForZoom(coords, zoom)
return origPosAndSideAtCoords.call(self, adjusted, precise)
}
// Override on the instance (shadows prototype methods)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(view as any).posAtCoords = function (
this: EditorView,
coords: Coords,
precise?: boolean,
) {
return zoomPosAtCoords(this, coords, precise)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(view as any).posAndSideAtCoords = function (
this: EditorView,
coords: Coords,
precise?: boolean,
) {
return zoomPosAndSideAtCoords(this, coords, precise)
}
return {
destroy() {
// Remove instance overrides, restoring prototype methods
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (view as any).posAtCoords
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (view as any).posAndSideAtCoords
},
}
})
}

View File

@@ -13,14 +13,13 @@ vi.mock('../utils/ai-chat', async () => {
...actual,
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
streamClaudeChatMock(...args)
// Simulate async: emit Init with session_id, then text, then done
// Simulate async: emit text, then done
const callbacks = args[3]
setTimeout(() => {
callbacks.onInit?.('session-001')
callbacks.onText('mock response')
callbacks.onDone()
}, 10)
return Promise.resolve('session-001')
return Promise.resolve('')
},
}
})
@@ -37,64 +36,38 @@ afterEach(() => {
})
describe('useAIChat', () => {
const emptyContent: Record<string, string> = {}
it('sends first message without session_id (new session)', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
it('sends first message as raw text without history', async () => {
const { result } = renderHook(() => useAIChat([]))
act(() => { result.current.sendMessage('hello') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
// First message: raw text, no session_id
// First message: raw text, no history wrapping, no session_id
expect(message).toBe('hello')
expect(sessionId).toBeUndefined()
})
it('resumes session on second message via --resume', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
it('embeds conversation history in second message', async () => {
const { result } = renderHook(() => useAIChat([]))
// Send first message
act(() => { result.current.sendMessage('What is Rust?') })
// Wait for mock response (which fires onInit with session-001)
// First exchange
act(() => { result.current.sendMessage('What is 2+2?') })
await act(async () => { vi.advanceTimersByTime(50) })
expect(result.current.messages).toHaveLength(2)
// Send second message — should resume with session_id
act(() => { result.current.sendMessage('Tell me more') })
// Second message — should include history from first exchange
act(() => { result.current.sendMessage('What is that times 3?') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
const [message, systemPrompt, sessionId] = streamClaudeChatMock.mock.calls[1]
// Raw message only (no embedded history)
expect(message).toBe('Tell me more')
// Session resumed via --resume
expect(sessionId).toBe('session-001')
// System prompt omitted on resumed sessions
expect(systemPrompt).toBeUndefined()
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('resets session on clearConversation', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send a message and get response
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Clear conversation
act(() => { result.current.clearConversation() })
expect(result.current.messages).toHaveLength(0)
// Send new message — should start a fresh session (no session_id)
act(() => { result.current.sendMessage('fresh start') })
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
expect(lastCall[0]).toBe('fresh start')
expect(lastCall[2]).toBeUndefined() // no session_id
})
it('resumes session across multiple exchanges', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
it('accumulates history across multiple exchanges', async () => {
const { result } = renderHook(() => useAIChat([]))
// Exchange 1
act(() => { result.current.sendMessage('Q1') })
@@ -109,43 +82,77 @@ describe('useAIChat', () => {
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
// First call: no session
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
// Second and third calls: resume session
expect(streamClaudeChatMock.mock.calls[1][2]).toBe('session-001')
expect(streamClaudeChatMock.mock.calls[2][2]).toBe('session-001')
// All messages are raw text (no embedded history)
// First call: no history
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
expect(streamClaudeChatMock.mock.calls[1][0]).toBe('Q2')
expect(streamClaudeChatMock.mock.calls[2][0]).toBe('Q3')
// 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('includes system prompt only on first message of a session', async () => {
const content = { 'note.md': 'Some note content' }
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
it('never passes session_id (no --resume)', async () => {
const { result } = renderHook(() => useAIChat([]))
const { result } = renderHook(() => useAIChat(content, notes))
act(() => { result.current.sendMessage('Q1') })
await act(async () => { vi.advanceTimersByTime(50) })
act(() => { result.current.sendMessage('Q2') })
// First message — system prompt included
// 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([]))
// 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 notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
const { result } = renderHook(() => useAIChat(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')
// Second message — system prompt omitted (session already has it)
act(() => { result.current.sendMessage('follow up') })
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
expect(secondSystemPrompt).toBeUndefined()
expect(secondSystemPrompt).toBeTruthy()
expect(secondSystemPrompt).toContain('Test Note')
})
it('resets session on retry', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
it('retries with correct history (excludes retried exchange)', async () => {
const { result } = renderHook(() => useAIChat([]))
// Send message and get response
// First exchange
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
expect(result.current.messages).toHaveLength(2)
@@ -153,9 +160,33 @@ describe('useAIChat', () => {
// Retry the assistant response (index 1)
act(() => { result.current.retryMessage(1) })
// Should start a fresh session
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
expect(lastCall[2]).toBeUndefined() // no session_id — fresh session
expect(lastCall[0]).toBe('hello') // re-sends the user message
// Should re-send the user message with no history (retrying first exchange)
expect(lastCall[0]).toBe('hello')
expect(lastCall[2]).toBeUndefined()
})
it('reads latest messages from ref (not stale closure)', async () => {
const { result } = renderHook(() => useAIChat([]))
// Send first message
act(() => { result.current.sendMessage('msg1') })
await act(async () => { vi.advanceTimersByTime(50) })
// Verify messages state is correct
expect(result.current.messages).toHaveLength(2)
expect(result.current.messages[0].content).toBe('msg1')
expect(result.current.messages[1].content).toBe('mock response')
// Send second message — the ref should have the latest messages
// even without depending on messages in useCallback deps
act(() => { result.current.sendMessage('msg2') })
const secondCall = streamClaudeChatMock.mock.calls[1]
const sentMessage = secondCall[0]
// Must contain history from first exchange
expect(sentMessage).toContain('[user]: msg1')
expect(sentMessage).toContain('[assistant]: mock response')
expect(sentMessage).toContain('[user]: msg2')
})
})

View File

@@ -2,21 +2,23 @@
* Custom hook encapsulating AI chat state and message handling.
* Uses Claude CLI subprocess via Tauri for streaming responses.
*
* Conversation continuity uses the CLI's --resume flag: the first message
* starts a new session; subsequent messages resume it via session_id.
* This avoids embedding history as text in the prompt (which the CLI's
* -p mode treats as a single user turn, losing turn boundaries).
* 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.
*
* Uses a ref (messagesRef) to read the latest messages in callbacks,
* avoiding stale closure issues with React's useCallback memoization.
*/
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, useEffect } from 'react'
import type { VaultEntry } from '../types'
import {
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
buildSystemPrompt, streamClaudeChat,
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
} from '../utils/ai-chat'
interface ChatStreamRefs {
abortRef: React.RefObject<boolean>
sessionIdRef: React.MutableRefObject<string | undefined>
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
@@ -28,7 +30,6 @@ function makeStreamCallbacks(
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
let accumulated = ''
const callbacks: ChatStreamCallbacks = {
onInit: (sid) => { refs.sessionIdRef.current = sid },
onText: (chunk) => {
if (refs.abortRef.current) return
accumulated += chunk
@@ -53,58 +54,67 @@ function makeStreamCallbacks(
}
export function useAIChat(
allContent: Record<string, string>,
contextNotes: VaultEntry[],
) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isStreaming, setIsStreaming] = useState(false)
const [streamingContent, setStreamingContent] = useState('')
const abortRef = useRef(false)
const sessionIdRef = useRef<string | undefined>(undefined)
const isStreamingRef = useRef(false)
const messagesRef = useRef<ChatMessage[]>([])
const sendMessage = useCallback((text: string) => {
if (!text.trim() || isStreaming) return
// Keep refs in sync with state — runs after render, before next user interaction.
useEffect(() => { messagesRef.current = messages }, [messages])
useEffect(() => { isStreamingRef.current = isStreaming }, [isStreaming])
/** Internal: send text, reading history from the messages ref. */
const doSend = useCallback((text: string, historyOverride?: ChatMessage[]) => {
if (!text.trim() || isStreamingRef.current) return
const history = historyOverride ?? messagesRef.current
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
setIsStreaming(true)
setStreamingContent('')
abortRef.current = false
const currentSessionId = sessionIdRef.current
// System prompt only on first message (new session).
const systemPrompt = currentSessionId
? undefined
: (buildSystemPrompt(contextNotes, allContent).prompt || undefined)
// Always include system prompt (each request is a fresh subprocess).
const systemPrompt = buildSystemPrompt(contextNotes).prompt || undefined
// Embed conversation history in the prompt for continuity.
const trimmedHistory = trimHistory(history, MAX_HISTORY_TOKENS)
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
const { callbacks } = makeStreamCallbacks({
abortRef, sessionIdRef, setMessages, setStreamingContent, setIsStreaming,
abortRef, setMessages, setStreamingContent, setIsStreaming,
})
streamClaudeChat(text.trim(), systemPrompt, currentSessionId, callbacks)
.then((sid) => {
if (sid && !sessionIdRef.current) sessionIdRef.current = sid
})
streamClaudeChat(formattedMessage, systemPrompt, undefined, callbacks)
.catch(() => { /* errors forwarded via onError */ })
}, [isStreaming, allContent, contextNotes])
}, [contextNotes])
const sendMessage = useCallback((text: string) => {
doSend(text)
}, [doSend])
const clearConversation = useCallback(() => {
abortRef.current = true
setMessages([])
setIsStreaming(false)
setStreamingContent('')
sessionIdRef.current = undefined
}, [])
const retryMessage = useCallback((msgIndex: number) => {
const currentMessages = messagesRef.current
const userMsgIndex = msgIndex - 1
if (userMsgIndex < 0) return
const userMsg = messages[userMsgIndex]
const userMsg = currentMessages[userMsgIndex]
if (userMsg.role !== 'user') return
sessionIdRef.current = undefined
setMessages(prev => prev.slice(0, msgIndex))
sendMessage(userMsg.content)
}, [messages, sendMessage])
const historyForRetry = currentMessages.slice(0, userMsgIndex)
setMessages(prev => prev.slice(0, userMsgIndex))
doSend(userMsg.content, historyForRetry)
}, [doSend])
return { messages, isStreaming, streamingContent, sendMessage, clearConversation, retryMessage }
}

View File

@@ -1,6 +1,15 @@
import { describe, it, expect, vi } from 'vitest'
import { detectFileOperation, parseBashFileCreation } from './useAiAgent'
import type { AgentFileCallbacks } from './useAiAgent'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { detectFileOperation, parseBashFileCreation, agentMessagesToChatHistory, useAiAgent } from './useAiAgent'
import type { AgentFileCallbacks, AiAgentMessage } from './useAiAgent'
import { streamClaudeAgent } from '../utils/ai-agent'
vi.mock('../utils/ai-agent', () => ({
streamClaudeAgent: vi.fn(),
buildAgentSystemPrompt: vi.fn(() => 'default-system-prompt'),
}))
const mockStreamClaudeAgent = vi.mocked(streamClaudeAgent)
const VAULT = '/Users/luca/Laputa'
@@ -176,3 +185,182 @@ describe('parseBashFileCreation', () => {
expect(parseBashFileCreation(input, VAULT)).toBeNull()
})
})
describe('useAiAgent', () => {
beforeEach(() => {
mockStreamClaudeAgent.mockReset()
mockStreamClaudeAgent.mockImplementation(async (_msg: string, _sys: string, _vault: string, callbacks: { onDone: () => void }) => {
callbacks.onDone()
})
})
it('updates sendMessage when contextPrompt changes (no stale closure)', () => {
// Mount with empty contextPrompt (simulates race: panel mounts before tab content loads)
const { result, rerender } = renderHook(
({ vault, context }) => useAiAgent(vault, context),
{ initialProps: { vault: VAULT, context: undefined as string | undefined } },
)
const firstSendMessage = result.current.sendMessage
// Simulate tab content arriving — re-render with real contextPrompt
rerender({ vault: VAULT, context: 'You are viewing note with body: Hello world' })
// sendMessage must be a NEW function that closes over the updated contextPrompt.
// If it's the same reference, it may read stale context (the race condition).
expect(result.current.sendMessage).not.toBe(firstSendMessage)
})
it('uses the current contextPrompt at send time, not the mount-time value', async () => {
const { result, rerender } = renderHook(
({ vault, context }) => useAiAgent(vault, context),
{ initialProps: { vault: VAULT, context: undefined as string | undefined } },
)
rerender({ vault: VAULT, context: 'You are viewing note with body: Hello world' })
await act(async () => {
await result.current.sendMessage('What does this note contain?')
})
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(1)
expect(mockStreamClaudeAgent.mock.calls[0][1]).toBe('You are viewing note with body: Hello world')
})
it('falls back to buildAgentSystemPrompt when contextPrompt is undefined', async () => {
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
await act(async () => {
await result.current.sendMessage('Hello')
})
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(1)
expect(mockStreamClaudeAgent.mock.calls[0][1]).toBe('default-system-prompt')
})
it('sends first message without conversation history', async () => {
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
await act(async () => {
await result.current.sendMessage('Hello')
})
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(1)
const sentMessage = mockStreamClaudeAgent.mock.calls[0][0]
expect(sentMessage).toBe('Hello')
expect(sentMessage).not.toContain('<conversation_history>')
})
it('embeds conversation history in second message', async () => {
// Mock that simulates a response for the first message
mockStreamClaudeAgent.mockImplementation(async (_msg, _sys, _vault, callbacks) => {
callbacks.onText('The answer is 4')
callbacks.onDone()
})
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
// First message
await act(async () => {
await result.current.sendMessage('What is 2+2?')
})
// Second message — should include history from first exchange
await act(async () => {
await result.current.sendMessage('What was my previous question?')
})
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(2)
// First call: no history
expect(mockStreamClaudeAgent.mock.calls[0][0]).toBe('What is 2+2?')
// Second call: includes history
const secondMsg = mockStreamClaudeAgent.mock.calls[1][0]
expect(secondMsg).toContain('<conversation_history>')
expect(secondMsg).toContain('What is 2+2?')
expect(secondMsg).toContain('The answer is 4')
expect(secondMsg).toContain('What was my previous question?')
})
it('accumulates history across multiple exchanges', async () => {
let callCount = 0
mockStreamClaudeAgent.mockImplementation(async (_msg, _sys, _vault, callbacks) => {
callCount++
callbacks.onText(`Response ${callCount}`)
callbacks.onDone()
})
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
await act(async () => { await result.current.sendMessage('Q1') })
await act(async () => { await result.current.sendMessage('Q2') })
await act(async () => { await result.current.sendMessage('Q3') })
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(3)
// First: no history
expect(mockStreamClaudeAgent.mock.calls[0][0]).toBe('Q1')
// Second: history from first exchange
const msg2 = mockStreamClaudeAgent.mock.calls[1][0]
expect(msg2).toContain('Q1')
expect(msg2).toContain('Response 1')
expect(msg2).toContain('Q2')
// Third: history from both exchanges
const msg3 = mockStreamClaudeAgent.mock.calls[2][0]
expect(msg3).toContain('Q1')
expect(msg3).toContain('Response 1')
expect(msg3).toContain('Q2')
expect(msg3).toContain('Response 2')
expect(msg3).toContain('Q3')
})
it('resets history after clearConversation', async () => {
mockStreamClaudeAgent.mockImplementation(async (_msg, _sys, _vault, callbacks) => {
callbacks.onText('reply')
callbacks.onDone()
})
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
await act(async () => { await result.current.sendMessage('hello') })
act(() => { result.current.clearConversation() })
await act(async () => { await result.current.sendMessage('fresh start') })
const lastCall = mockStreamClaudeAgent.mock.calls[mockStreamClaudeAgent.mock.calls.length - 1]
expect(lastCall[0]).toBe('fresh start')
expect(lastCall[0]).not.toContain('<conversation_history>')
})
})
describe('agentMessagesToChatHistory', () => {
it('returns empty array for no messages', () => {
expect(agentMessagesToChatHistory([])).toEqual([])
})
it('converts agent messages to user/assistant pairs', () => {
const msgs: AiAgentMessage[] = [
{ userMessage: 'Q1', response: 'A1', actions: [], id: 'm1' },
{ userMessage: 'Q2', response: 'A2', actions: [], id: 'm2' },
]
const result = agentMessagesToChatHistory(msgs)
expect(result).toHaveLength(4)
expect(result[0]).toMatchObject({ role: 'user', content: 'Q1' })
expect(result[1]).toMatchObject({ role: 'assistant', content: 'A1' })
expect(result[2]).toMatchObject({ role: 'user', content: 'Q2' })
expect(result[3]).toMatchObject({ role: 'assistant', content: 'A2' })
})
it('skips assistant entry when response is undefined (still streaming)', () => {
const msgs: AiAgentMessage[] = [
{ userMessage: 'Q1', actions: [], id: 'm1', isStreaming: true },
]
const result = agentMessagesToChatHistory(msgs)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ role: 'user', content: 'Q1' })
})
})

View File

@@ -14,7 +14,10 @@ import { useState, useCallback, useRef, useEffect } from 'react'
import type { AiAction } from '../components/AiMessage'
import type { NoteReference } from '../utils/ai-context'
import { streamClaudeAgent, buildAgentSystemPrompt } from '../utils/ai-agent'
import { nextMessageId } from '../utils/ai-chat'
import {
nextMessageId, trimHistory, formatMessageWithHistory,
type ChatMessage, MAX_HISTORY_TOKENS,
} from '../utils/ai-chat'
export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'error'
@@ -36,6 +39,18 @@ export interface AgentFileCallbacks {
onVaultChanged?: () => void
}
/** Convert completed agent messages to ChatMessage pairs for history embedding. */
export function agentMessagesToChatHistory(msgs: AiAgentMessage[]): ChatMessage[] {
const history: ChatMessage[] = []
for (const msg of msgs) {
history.push({ role: 'user', content: msg.userMessage, id: msg.id ?? '' })
if (msg.response) {
history.push({ role: 'assistant', content: msg.response, id: `${msg.id}-resp` })
}
}
return history
}
export function useAiAgent(
vaultPath: string,
contextPrompt?: string,
@@ -44,21 +59,24 @@ export function useAiAgent(
const [messages, setMessages] = useState<AiAgentMessage[]>([])
const [status, setStatus] = useState<AgentStatus>('idle')
const abortRef = useRef({ aborted: false })
const contextRef = useRef(contextPrompt)
const responseAccRef = useRef('')
const fileCallbacksRef = useRef(fileCallbacks)
// Track tool inputs for file-operation detection on ToolDone
const toolInputMapRef = useRef<Map<string, { tool: string; input?: string }>>(new Map())
// Refs for latest state — avoids stale closures in callbacks.
// Synced via useEffect (runs after render, before next user interaction).
const messagesRef = useRef<AiAgentMessage[]>([])
const statusRef = useRef<AgentStatus>('idle')
useEffect(() => { messagesRef.current = messages }, [messages])
useEffect(() => { statusRef.current = status }, [status])
useEffect(() => {
contextRef.current = contextPrompt
}, [contextPrompt])
useEffect(() => {
fileCallbacksRef.current = fileCallbacks
}, [fileCallbacks])
const sendMessage = useCallback(async (text: string, references?: NoteReference[]) => {
if (!text.trim() || status === 'thinking' || status === 'tool-executing') return
const currentStatus = statusRef.current
if (!text.trim() || currentStatus === 'thinking' || currentStatus === 'tool-executing') return
const refs = references && references.length > 0 ? references : undefined
@@ -89,9 +107,15 @@ export function useAiAgent(
update(m => m.reasoningDone ? m : { ...m, reasoningDone: true })
}
const systemPrompt = contextRef.current ?? buildAgentSystemPrompt()
const systemPrompt = contextPrompt ?? buildAgentSystemPrompt()
await streamClaudeAgent(text.trim(), systemPrompt, vaultPath, {
// Embed conversation history from previous exchanges.
// Uses messagesRef (not closure-captured messages) to avoid stale closures.
const chatHistory = agentMessagesToChatHistory(messagesRef.current.filter(m => !m.isStreaming))
const trimmedHistory = trimHistory(chatHistory, MAX_HISTORY_TOKENS)
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
await streamClaudeAgent(formattedMessage, systemPrompt, vaultPath, {
onThinking: (chunk) => {
if (abortRef.current.aborted) return
update(m => ({ ...m, reasoning: (m.reasoning ?? '') + chunk }))
@@ -178,7 +202,7 @@ export function useAiAgent(
fileCallbacksRef.current?.onVaultChanged?.()
},
})
}, [status, vaultPath])
}, [vaultPath, contextPrompt])
const clearConversation = useCallback(() => {
abortRef.current.aborted = true

View File

@@ -15,7 +15,6 @@ interface AppCommandsConfig {
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
tabs: Tab[]
entries: VaultEntry[]
allContent: Record<string, string>
modifiedCount: number
selection: SidebarSelection
onQuickOpen: () => void
@@ -67,6 +66,7 @@ interface AppCommandsConfig {
mcpStatus?: string
onInstallMcp?: () => void
onReindexVault?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
}
@@ -151,6 +151,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onViewChanges: viewChanges,
onInstallMcp: config.onInstallMcp,
onReindexVault: config.onReindexVault,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
@@ -206,6 +207,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
onReindexVault: config.onReindexVault,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
})
@@ -214,7 +216,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
activeTabPath: config.activeTabPath,
entries: config.entries,
selection: config.selection,
allContent: config.allContent,
onSwitchTab: config.onSwitchTab,
onReplaceActiveTab: config.onReplaceActiveTab,
onSelectNote: config.onSelectNote,

View File

@@ -0,0 +1,79 @@
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()
})
it('installs zoomCursorFix that overrides posAtCoords on the view instance', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello world', false, noopCallbacks),
)
const view = result.current.current!
// The extension overrides posAtCoords on the instance (not the prototype)
expect(Object.prototype.hasOwnProperty.call(view, 'posAtCoords')).toBe(true)
})
})

View File

@@ -3,6 +3,7 @@ import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirro
import { EditorState } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight'
import { zoomCursorFix } from '../extensions/zoomCursorFix'
const FONT_FAMILY = '"Berkeley Mono", "JetBrains Mono", "Fira Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -98,6 +99,7 @@ export function useCodeMirror(
buildBaseTheme(isDark),
frontmatterHighlightTheme(isDark),
frontmatterHighlightPlugin,
zoomCursorFix(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
callbacksRef.current.onDocChange(update.state.doc.toString())
@@ -111,7 +113,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

@@ -255,6 +255,49 @@ describe('install-mcp command', () => {
})
})
describe('reload-vault command', () => {
it('is present in Settings group', () => {
const config = makeConfig({ onReloadVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reload-vault')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('Settings')
expect(cmd!.label).toBe('Reload Vault')
})
it('is enabled when onReloadVault is provided', () => {
const config = makeConfig({ onReloadVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reload-vault')
expect(cmd!.enabled).toBe(true)
})
it('is disabled when onReloadVault is not provided', () => {
const config = makeConfig()
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reload-vault')
expect(cmd!.enabled).toBe(false)
})
it('executes onReloadVault callback', () => {
const onReloadVault = vi.fn()
const config = makeConfig({ onReloadVault })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reload-vault')
cmd!.execute()
expect(onReloadVault).toHaveBeenCalled()
})
it('has searchable keywords', () => {
const config = makeConfig({ onReloadVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reload-vault')
expect(cmd!.keywords).toContain('reload')
expect(cmd!.keywords).toContain('refresh')
expect(cmd!.keywords).toContain('rescan')
})
})
describe('buildTypeCommands', () => {
it('creates new and list commands for each type', () => {
const onCreateNoteOfType = vi.fn()

View File

@@ -21,6 +21,7 @@ interface CommandRegistryConfig {
mcpStatus?: string
onInstallMcp?: () => void
onReindexVault?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onQuickOpen: () => void
@@ -199,6 +200,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onReindexVault,
onReloadVault,
onRepairVault,
} = config
@@ -262,6 +264,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
{ 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: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
{ 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]"
@@ -281,6 +284,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onReindexVault, onRepairVault,
onReindexVault, onReloadVault, onRepairVault,
])
}

View File

@@ -171,6 +171,22 @@ describe('useEditorSave', () => {
// (We'll check via the next handleSave call)
})
it('handleContentChange syncs content to tab state immediately', () => {
const { result } = renderSaveHook()
act(() => {
result.current.handleContentChange('/test/note.md', '---\ntitle: T\n---\n\n# T\n\nLive edits')
})
// setTabs must be called on every content change (not just on save)
// so that consumers like the AI panel see current editor content
expect(setTabs).toHaveBeenCalled()
const updater = setTabs.mock.calls[0][0]
const tabs = [{ entry: { path: '/test/note.md' }, content: 'stale' }]
const updated = updater(tabs)
expect(updated[0].content).toBe('---\ntitle: T\n---\n\n# T\n\nLive edits')
})
it('save updates tab content with edited body, not original (regression)', async () => {
const { result } = renderSaveHook()

View File

@@ -66,10 +66,14 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
}
}, [flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
/** Called by Editor onChange — buffers the latest content without saving */
/** Called by Editor onChange — buffers the latest content and syncs tab state
* so consumers (e.g. AI panel) always see current editor content. */
const handleContentChange = useCallback((path: string, content: string) => {
pendingContentRef.current = { path, content }
}, [])
setTabs((prev: Tab[]) =>
prev.map((t) => t.entry.path === path ? { ...t, content } : t)
)
}, [setTabs])
/** Save pending content for a specific path (used before rename) */
const savePendingForPath = useCallback(

View File

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

View File

@@ -342,4 +342,120 @@ describe('useEntryActions', () => {
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
})
})
describe('failed disk writes do not update React state', () => {
it('handleCustomizeType does not update entry when frontmatter write fails', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' })
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
const { result } = setup([typeEntry])
await expect(
act(() => result.current.handleCustomizeType('Recipe', 'star', 'red'))
).rejects.toThrow('disk full')
expect(updateEntry).not.toHaveBeenCalled()
})
it('handleRenameSection does not update entry when frontmatter write fails', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' })
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
const { result } = setup([typeEntry])
await expect(
act(() => result.current.handleRenameSection('Recipe', 'Dishes'))
).rejects.toThrow('disk full')
expect(updateEntry).not.toHaveBeenCalled()
})
it('handleRenameSection does not update entry when delete property fails', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: 'Dishes' })
handleDeleteProperty.mockRejectedValueOnce(new Error('disk full'))
const { result } = setup([typeEntry])
await expect(
act(() => result.current.handleRenameSection('Recipe', ''))
).rejects.toThrow('disk full')
expect(updateEntry).not.toHaveBeenCalled()
})
it('handleToggleTypeVisibility does not update entry when frontmatter write fails (hide)', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: null })
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
const { result } = setup([typeEntry])
await expect(
act(() => result.current.handleToggleTypeVisibility('Journal'))
).rejects.toThrow('disk full')
expect(updateEntry).not.toHaveBeenCalled()
})
it('handleToggleTypeVisibility does not update entry when delete property fails (show)', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: false })
handleDeleteProperty.mockRejectedValueOnce(new Error('disk full'))
const { result } = setup([typeEntry])
await expect(
act(() => result.current.handleToggleTypeVisibility('Journal'))
).rejects.toThrow('disk full')
expect(updateEntry).not.toHaveBeenCalled()
})
})
describe('onBeforeAction callback', () => {
function setupWithBeforeAction(onBeforeAction: ReturnType<typeof vi.fn>) {
return renderHook(() =>
useEntryActions({
entries: [], updateEntry, handleUpdateFrontmatter, handleDeleteProperty,
setToastMessage, createTypeEntry, onFrontmatterPersisted, onBeforeAction,
})
)
}
it('calls onBeforeAction before trashing a note', async () => {
const callOrder: string[] = []
const onBeforeAction = vi.fn().mockImplementation(() => {
callOrder.push('beforeAction')
return Promise.resolve()
})
handleUpdateFrontmatter.mockImplementation(() => {
callOrder.push('updateFrontmatter')
return Promise.resolve()
})
const { result } = setupWithBeforeAction(onBeforeAction)
await act(async () => {
await result.current.handleTrashNote('/vault/note/test.md')
})
expect(onBeforeAction).toHaveBeenCalledWith('/vault/note/test.md')
expect(callOrder[0]).toBe('beforeAction')
expect(callOrder[1]).toBe('updateFrontmatter')
})
it('calls onBeforeAction before archiving a note', async () => {
const onBeforeAction = vi.fn().mockResolvedValue(undefined)
const { result } = setupWithBeforeAction(onBeforeAction)
await act(async () => {
await result.current.handleArchiveNote('/vault/note/test.md')
})
expect(onBeforeAction).toHaveBeenCalledWith('/vault/note/test.md')
})
it.each([
['trash', 'handleTrashNote'] as const,
['archive', 'handleArchiveNote'] as const,
])('does not proceed with %s when onBeforeAction rejects', async (_label, method) => {
const { result } = setupWithBeforeAction(vi.fn().mockRejectedValue(new Error('Save failed')))
await expect(act(() => result.current[method]('/vault/note/test.md'))).rejects.toThrow('Save failed')
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
})
})
})

View File

@@ -9,23 +9,32 @@ interface EntryActionsConfig {
setToastMessage: (msg: string | null) => void
createTypeEntry: (typeName: string) => Promise<VaultEntry>
onFrontmatterPersisted?: () => void
/** Called before trash/archive to flush unsaved editor content to disk. */
onBeforeAction?: (path: string) => Promise<void>
}
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
return entries.find((e) => e.isA === 'Type' && e.title === typeName)
}
async function findOrCreateType(
entries: VaultEntry[], typeName: string, create: (name: string) => Promise<VaultEntry>,
): Promise<VaultEntry> {
return findTypeEntry(entries, typeName) ?? await create(typeName)
}
export function useEntryActions({
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, onFrontmatterPersisted,
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, onFrontmatterPersisted, onBeforeAction,
}: EntryActionsConfig) {
const handleTrashNote = useCallback(async (path: string) => {
await onBeforeAction?.(path)
const now = new Date().toISOString().slice(0, 10)
await handleUpdateFrontmatter(path, 'Trashed', true)
await handleUpdateFrontmatter(path, 'Trashed at', now)
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
setToastMessage('Note moved to trash')
onFrontmatterPersisted?.()
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
}, [onBeforeAction, handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
const handleRestoreNote = useCallback(async (path: string) => {
await handleUpdateFrontmatter(path, 'Trashed', false)
@@ -36,11 +45,12 @@ export function useEntryActions({
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
const handleArchiveNote = useCallback(async (path: string) => {
await onBeforeAction?.(path)
await handleUpdateFrontmatter(path, 'archived', true)
updateEntry(path, { archived: true })
setToastMessage('Note archived')
onFrontmatterPersisted?.()
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
}, [onBeforeAction, handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
const handleUnarchiveNote = useCallback(async (path: string) => {
await handleUpdateFrontmatter(path, 'archived', false)
@@ -50,18 +60,16 @@ export function useEntryActions({
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
updateEntry(typeEntry.path, { icon, color })
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
updateEntry(typeEntry.path, { icon, color })
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleReorderSections = useCallback(async (orderedTypes: { typeName: string; order: number }[]) => {
for (const { typeName, order } of orderedTypes) {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
await handleUpdateFrontmatter(typeEntry.path, 'order', order)
updateEntry(typeEntry.path, { order })
}
@@ -69,35 +77,32 @@ export function useEntryActions({
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleUpdateTypeTemplate = useCallback(async (typeName: string, template: string) => {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
await handleUpdateFrontmatter(typeEntry.path, 'template', template)
updateEntry(typeEntry.path, { template: template || null })
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
const trimmed = label.trim()
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
if (trimmed) {
await handleUpdateFrontmatter(typeEntry.path, 'sidebar label', trimmed)
} else {
await handleDeleteProperty(typeEntry.path, 'sidebar label')
}
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
let typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
if (typeEntry.visible === false) {
updateEntry(typeEntry.path, { visible: null })
await handleDeleteProperty(typeEntry.path, 'visible')
updateEntry(typeEntry.path, { visible: null })
} else {
updateEntry(typeEntry.path, { visible: false })
await handleUpdateFrontmatter(typeEntry.path, 'visible', false)
updateEntry(typeEntry.path, { visible: false })
}
onFrontmatterPersisted?.()
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])

View File

@@ -59,8 +59,6 @@ describe('useKeyboardNavigation', () => {
]
const selection: SidebarSelection = { kind: 'filter', filter: 'all' }
const allContent: Record<string, string> = {}
let addedListeners: { type: string; handler: EventListenerOrEventListenerObject }[] = []
beforeEach(() => {
@@ -85,7 +83,7 @@ describe('useKeyboardNavigation', () => {
it('registers keydown listener on mount', () => {
renderHook(() =>
useKeyboardNavigation({
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
tabs, activeTabPath: '/vault/a.md', entries, selection,
onSwitchTab, onReplaceActiveTab, onSelectNote,
})
)
@@ -96,7 +94,7 @@ describe('useKeyboardNavigation', () => {
it('switches to next tab on Cmd+Shift+ArrowRight (browser mode)', () => {
renderHook(() =>
useKeyboardNavigation({
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
tabs, activeTabPath: '/vault/a.md', entries, selection,
onSwitchTab, onReplaceActiveTab, onSelectNote,
})
)
@@ -113,7 +111,7 @@ describe('useKeyboardNavigation', () => {
it('switches to previous tab on Cmd+Shift+ArrowLeft (browser mode)', () => {
renderHook(() =>
useKeyboardNavigation({
tabs, activeTabPath: '/vault/b.md', entries, selection, allContent,
tabs, activeTabPath: '/vault/b.md', entries, selection,
onSwitchTab, onReplaceActiveTab, onSelectNote,
})
)
@@ -130,7 +128,7 @@ describe('useKeyboardNavigation', () => {
it('wraps around when navigating past last tab', () => {
renderHook(() =>
useKeyboardNavigation({
tabs, activeTabPath: '/vault/c.md', entries, selection, allContent,
tabs, activeTabPath: '/vault/c.md', entries, selection,
onSwitchTab, onReplaceActiveTab, onSelectNote,
})
)
@@ -147,7 +145,7 @@ describe('useKeyboardNavigation', () => {
it('navigates to next note on Cmd+Alt+ArrowDown', () => {
renderHook(() =>
useKeyboardNavigation({
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
tabs, activeTabPath: '/vault/a.md', entries, selection,
onSwitchTab, onReplaceActiveTab, onSelectNote,
})
)
@@ -164,7 +162,7 @@ describe('useKeyboardNavigation', () => {
it('navigates to previous note on Cmd+Alt+ArrowUp', () => {
renderHook(() =>
useKeyboardNavigation({
tabs, activeTabPath: '/vault/b.md', entries, selection, allContent,
tabs, activeTabPath: '/vault/b.md', entries, selection,
onSwitchTab, onReplaceActiveTab, onSelectNote,
})
)
@@ -181,7 +179,7 @@ describe('useKeyboardNavigation', () => {
it('selects first note when no active tab', () => {
renderHook(() =>
useKeyboardNavigation({
tabs: [], activeTabPath: null, entries, selection, allContent,
tabs: [], activeTabPath: null, entries, selection,
onSwitchTab, onReplaceActiveTab, onSelectNote,
})
)
@@ -198,7 +196,7 @@ describe('useKeyboardNavigation', () => {
it('does nothing without modifier keys', () => {
renderHook(() =>
useKeyboardNavigation({
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
tabs, activeTabPath: '/vault/a.md', entries, selection,
onSwitchTab, onReplaceActiveTab, onSelectNote,
})
)
@@ -217,7 +215,7 @@ describe('useKeyboardNavigation', () => {
it('does nothing with empty tabs for tab navigation', () => {
renderHook(() =>
useKeyboardNavigation({
tabs: [], activeTabPath: null, entries, selection, allContent,
tabs: [], activeTabPath: null, entries, selection,
onSwitchTab, onReplaceActiveTab, onSelectNote,
})
)

View File

@@ -13,7 +13,6 @@ interface KeyboardNavigationOptions {
activeTabPath: string | null
entries: VaultEntry[]
selection: SidebarSelection
allContent: Record<string, string>
onSwitchTab: (path: string) => void
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
@@ -22,10 +21,9 @@ interface KeyboardNavigationOptions {
function computeVisibleNotes(
entries: VaultEntry[],
selection: SidebarSelection,
allContent: Record<string, string>,
): VaultEntry[] {
if (selection.kind === 'entity') {
return buildRelationshipGroups(selection.entry, entries, allContent)
return buildRelationshipGroups(selection.entry, entries)
.flatMap((g) => g.entries)
}
return [...filterEntries(entries, selection)].sort(sortByModified)
@@ -93,12 +91,12 @@ function useLatestRef<T>(value: T): React.RefObject<T> {
}
export function useKeyboardNavigation({
tabs, activeTabPath, entries, selection, allContent,
tabs, activeTabPath, entries, selection,
onSwitchTab, onReplaceActiveTab, onSelectNote,
}: KeyboardNavigationOptions) {
const visibleNotes = useMemo(
() => computeVisibleNotes(entries, selection, allContent),
[entries, selection, allContent],
() => computeVisibleNotes(entries, selection),
[entries, selection],
)
const tabsRef = useLatestRef(tabs)

View File

@@ -35,6 +35,7 @@ function makeHandlers(): MenuEventHandlers {
onViewChanges: vi.fn(),
onInstallMcp: vi.fn(),
onReindexVault: vi.fn(),
onReloadVault: 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',
@@ -306,6 +307,12 @@ describe('dispatchMenuEvent', () => {
expect(h.onReindexVault).toHaveBeenCalled()
})
it('vault-reload triggers reload vault', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-reload', h)
expect(h.onReloadVault).toHaveBeenCalled()
})
// Edge cases
it('unknown event ID does nothing', () => {
const h = makeHandlers()

View File

@@ -36,6 +36,7 @@ export interface MenuEventHandlers {
onViewChanges?: () => void
onInstallMcp?: () => void
onReindexVault?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
@@ -79,7 +80,7 @@ type OptionalHandler =
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
| 'onCreateTheme' | 'onRestoreDefaultThemes'
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onRepairVault'
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'view-go-back': 'onGoBack',
@@ -99,6 +100,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'vault-view-changes': 'onViewChanges',
'vault-install-mcp': 'onInstallMcp',
'vault-reindex': 'onReindexVault',
'vault-reload': 'onReloadVault',
'vault-repair': 'onRepairVault',
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import {
slugify,
@@ -241,8 +241,8 @@ describe('DEFAULT_TEMPLATES', () => {
describe('resolveNewNote', () => {
it('uses TYPE_FOLDER_MAP for known types', () => {
const { entry, content } = resolveNewNote('My Project', 'Project')
expect(entry.path).toBe('/Users/luca/Laputa/project/my-project.md')
const { entry, content } = resolveNewNote('My Project', 'Project', '/my/vault')
expect(entry.path).toBe('/my/vault/project/my-project.md')
expect(entry.isA).toBe('Project')
expect(entry.status).toBe('Active')
expect(content).toContain('type: Project')
@@ -250,31 +250,43 @@ describe('resolveNewNote', () => {
})
it('falls back to slugified type for custom types', () => {
const { entry } = resolveNewNote('First Recipe', 'Recipe')
expect(entry.path).toBe('/Users/luca/Laputa/recipe/first-recipe.md')
const { entry } = resolveNewNote('First Recipe', 'Recipe', '/my/vault')
expect(entry.path).toBe('/my/vault/recipe/first-recipe.md')
})
it('omits status for Topic type', () => {
const { entry, content } = resolveNewNote('Machine Learning', 'Topic')
const { entry, content } = resolveNewNote('Machine Learning', 'Topic', '/my/vault')
expect(entry.status).toBeNull()
expect(content).not.toContain('status:')
})
it('omits status for Person type', () => {
const { entry } = resolveNewNote('John Doe', 'Person')
const { entry } = resolveNewNote('John Doe', 'Person', '/my/vault')
expect(entry.status).toBeNull()
})
it('uses provided vault path instead of hardcoded path', () => {
const { entry } = resolveNewNote('Test', 'Note', '/other/vault')
expect(entry.path).toBe('/other/vault/note/test.md')
expect(entry.path).not.toContain('/Users/luca/Laputa')
})
})
describe('resolveNewType', () => {
it('creates a type entry in the type folder', () => {
const { entry, content } = resolveNewType('Recipe')
expect(entry.path).toBe('/Users/luca/Laputa/type/recipe.md')
const { entry, content } = resolveNewType('Recipe', '/my/vault')
expect(entry.path).toBe('/my/vault/type/recipe.md')
expect(entry.isA).toBe('Type')
expect(entry.status).toBeNull()
expect(content).toContain('type: Type')
expect(content).toContain('# Recipe')
})
it('uses provided vault path instead of hardcoded path', () => {
const { entry } = resolveNewType('Responsibility', '/other/vault')
expect(entry.path).toBe('/other/vault/type/responsibility.md')
expect(entry.path).not.toContain('/Users/luca/Laputa')
})
})
describe('frontmatterToEntryPatch', () => {
@@ -365,8 +377,8 @@ describe('buildDailyNoteContent', () => {
describe('resolveDailyNote', () => {
it('creates entry in journal folder with date as filename', () => {
const { entry } = resolveDailyNote('2026-03-02')
expect(entry.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md')
const { entry } = resolveDailyNote('2026-03-02', '/my/vault')
expect(entry.path).toBe('/my/vault/journal/2026-03-02.md')
expect(entry.filename).toBe('2026-03-02.md')
expect(entry.title).toBe('2026-03-02')
expect(entry.isA).toBe('Journal')
@@ -374,10 +386,16 @@ describe('resolveDailyNote', () => {
})
it('returns content with daily note template', () => {
const { content } = resolveDailyNote('2026-03-02')
const { content } = resolveDailyNote('2026-03-02', '/my/vault')
expect(content).toContain('type: Journal')
expect(content).toContain('## Intentions')
})
it('uses provided vault path instead of hardcoded path', () => {
const { entry } = resolveDailyNote('2026-03-02', '/other/vault')
expect(entry.path).toBe('/other/vault/journal/2026-03-02.md')
expect(entry.path).not.toContain('/Users/luca/Laputa')
})
})
describe('findDailyNote', () => {
@@ -408,12 +426,11 @@ describe('findDailyNote', () => {
describe('useNoteActions hook', () => {
const addEntry = vi.fn()
const removeEntry = vi.fn()
const updateContent = vi.fn()
const updateEntry = vi.fn()
const setToastMessage = vi.fn()
const makeConfig = (entries: VaultEntry[] = []): NoteActionsConfig => ({
addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry,
addEntry, removeEntry, entries, setToastMessage, updateEntry, vaultPath: '/test/vault',
})
beforeEach(() => {
@@ -429,11 +446,10 @@ describe('useNoteActions hook', () => {
})
expect(addEntry).toHaveBeenCalledTimes(1)
const [createdEntry, createdContent] = addEntry.mock.calls[0]
const [createdEntry] = addEntry.mock.calls[0]
expect(createdEntry.title).toBe('Test Note')
expect(createdEntry.isA).toBe('Note')
expect(createdEntry.path).toContain('note/test-note.md')
expect(createdContent).toContain('title: Test Note')
})
it('handleCreateNote opens tab immediately (before addEntry resolves)', () => {
@@ -499,7 +515,6 @@ describe('useNoteActions hook', () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
})
expect(updateContent).toHaveBeenCalled()
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: 'Done' })
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
})
@@ -526,7 +541,6 @@ describe('useNoteActions hook', () => {
await result.current.handleDeleteProperty('/vault/note.md', 'status')
})
expect(updateContent).toHaveBeenCalled()
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: null })
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
})
@@ -580,9 +594,9 @@ describe('useNoteActions hook', () => {
result.current.handleCreateNote('My Project', 'Project')
})
const [, createdContent] = addEntry.mock.calls[0]
expect(createdContent).toContain('## Objective')
expect(createdContent).toContain('## Key Results')
const tabContent = result.current.tabs[0].content
expect(tabContent).toContain('## Objective')
expect(tabContent).toContain('## Key Results')
})
it('handleCreateNote uses custom template from type entry', () => {
@@ -593,9 +607,9 @@ describe('useNoteActions hook', () => {
result.current.handleCreateNote('Pasta', 'Recipe')
})
const [, createdContent] = addEntry.mock.calls[0]
expect(createdContent).toContain('## Ingredients')
expect(createdContent).toContain('## Steps')
const tabContent = result.current.tabs[0].content
expect(tabContent).toContain('## Ingredients')
expect(tabContent).toContain('## Steps')
})
it('handleCreateNoteImmediate uses template for typed notes', () => {
@@ -606,8 +620,8 @@ describe('useNoteActions hook', () => {
result.current.handleCreateNoteImmediate('Project')
})
const [, createdContent] = addEntry.mock.calls[0]
expect(createdContent).toContain('## Custom Template')
const tabContent = result.current.tabs[0].content
expect(tabContent).toContain('## Custom Template')
})
it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => {
@@ -835,4 +849,106 @@ describe('useNoteActions hook', () => {
expect(clearUnsaved).toHaveBeenCalledWith(createdPath)
})
})
describe('move note to type folder on type change', () => {
it('calls move_note_to_type_folder when type key is updated', async () => {
const entry = makeEntry({ path: '/test/vault/note/my-note.md', filename: 'my-note.md', title: 'My Note', isA: 'Note' })
const replaceEntry = vi.fn()
const config = makeConfig([entry])
config.replaceEntry = replaceEntry
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/quarter/my-note.md', updated_links: 0, moved: true }
if (cmd === 'get_note_content') return '---\ntype: Quarter\n---\n# My Note\n'
return ''
})
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleUpdateFrontmatter('/test/vault/note/my-note.md', 'type', 'Quarter')
})
expect(mockInvoke).toHaveBeenCalledWith('move_note_to_type_folder', expect.objectContaining({
vault_path: '/test/vault',
note_path: '/test/vault/note/my-note.md',
new_type: 'Quarter',
}))
expect(replaceEntry).toHaveBeenCalledWith(
'/test/vault/note/my-note.md',
expect.objectContaining({ path: '/test/vault/quarter/my-note.md' }),
)
expect(setToastMessage).toHaveBeenCalledWith('Note moved to quarter/')
})
it('does not call move when type key is not being changed', async () => {
const config = makeConfig()
vi.mocked(mockInvoke).mockResolvedValue('')
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
})
expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything())
})
it('does not move when result.moved is false (already in correct folder)', async () => {
const entry = makeEntry({ path: '/test/vault/quarter/my-note.md', filename: 'my-note.md', isA: 'Quarter' })
const replaceEntry = vi.fn()
const config = makeConfig([entry])
config.replaceEntry = replaceEntry
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/quarter/my-note.md', updated_links: 0, moved: false }
return ''
})
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleUpdateFrontmatter('/test/vault/quarter/my-note.md', 'type', 'Quarter')
})
expect(replaceEntry).not.toHaveBeenCalled()
// Should still show 'Property updated' toast (not move toast)
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
})
it('handles Is A key (case-insensitive)', async () => {
const entry = makeEntry({ path: '/test/vault/note/my-note.md' })
const config = makeConfig([entry])
config.replaceEntry = vi.fn()
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/project/my-note.md', updated_links: 0, moved: true }
if (cmd === 'get_note_content') return '---\nIs A: Project\n---\n# My Note\n'
return ''
})
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleUpdateFrontmatter('/test/vault/note/my-note.md', 'Is A', 'Project')
})
expect(mockInvoke).toHaveBeenCalledWith('move_note_to_type_folder', expect.objectContaining({
new_type: 'Project',
}))
})
it('does not move when value is empty or null-like', async () => {
const config = makeConfig()
vi.mocked(mockInvoke).mockResolvedValue('')
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleUpdateFrontmatter('/vault/note.md', 'type', '')
})
expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything())
})
})
})

View File

@@ -19,13 +19,19 @@ interface RenameResult {
updated_files: number
}
interface MoveResult {
new_path: string
updated_links: number
moved: boolean
}
export interface NoteActionsConfig {
addEntry: (entry: VaultEntry, content: string) => void
addEntry: (entry: VaultEntry) => void
removeEntry: (path: string) => void
updateContent: (path: string, content: string) => void
entries: VaultEntry[]
setToastMessage: (msg: string | null) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
vaultPath: string
addPendingSave?: (path: string) => void
removePendingSave?: (path: string) => void
trackUnsaved?: (path: string) => void
@@ -35,6 +41,23 @@ export interface NoteActionsConfig {
markContentPending?: (path: string, content: string) => void
/** Called after a new note is persisted to disk (e.g. to refresh git status for Changes view). */
onNewNotePersisted?: () => void
/** Replace an entry at oldPath with a patch (handles path changes in entries). */
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
}
async function performMoveToTypeFolder(
vaultPath: string, notePath: string, newType: string,
): Promise<MoveResult> {
if (isTauri()) {
return invoke<MoveResult>('move_note_to_type_folder', { vaultPath, notePath, newType })
}
return mockInvoke<MoveResult>('move_note_to_type_folder', { vault_path: vaultPath, note_path: notePath, new_type: newType })
}
/** Check if a frontmatter key represents the note type. */
function isTypeKey(key: string): boolean {
const k = key.toLowerCase().replace(/\s+/g, '_')
return k === 'type' || k === 'is_a'
}
async function performRename(
@@ -175,9 +198,9 @@ export function frontmatterToEntryPatch(
return updates[k] ?? {}
}
function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry, c: string) => void) {
function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry) => void) {
if (!isTauri()) addMockEntry(entry, content)
addEntry(entry, content)
addEntry(entry)
}
export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string {
@@ -188,17 +211,17 @@ export function buildNoteContent(title: string, type: string, status: string | n
return `${lines.join('\n')}\n\n# ${title}\n${body}`
}
export function resolveNewNote(title: string, type: string, template?: string | null): { entry: VaultEntry; content: string } {
export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { entry: VaultEntry; content: string } {
const folder = TYPE_FOLDER_MAP[type] || slugify(type)
const slug = slugify(title)
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
const entry = buildNewEntry({ path: `/Users/luca/Laputa/${folder}/${slug}.md`, slug, title, type, status })
const entry = buildNewEntry({ path: `${vaultPath}/${folder}/${slug}.md`, slug, title, type, status })
return { entry, content: buildNoteContent(title, type, status, template) }
}
export function resolveNewType(typeName: string): { entry: VaultEntry; content: string } {
export function resolveNewType(typeName: string, vaultPath: string): { entry: VaultEntry; content: string } {
const slug = slugify(typeName)
const entry = buildNewEntry({ path: `/Users/luca/Laputa/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
const entry = buildNewEntry({ path: `${vaultPath}/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n\n` }
}
@@ -211,8 +234,8 @@ export function buildDailyNoteContent(date: string): string {
return `${lines.join('\n')}\n\n# ${date}\n\n## Intentions\n\n\n\n## Reflections\n\n`
}
export function resolveDailyNote(date: string): { entry: VaultEntry; content: string } {
const entry = buildNewEntry({ path: `/Users/luca/Laputa/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
export function resolveDailyNote(date: string, vaultPath: string): { entry: VaultEntry; content: string } {
const entry = buildNewEntry({ path: `${vaultPath}/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
return { entry, content: buildDailyNoteContent(date) }
}
@@ -224,11 +247,11 @@ export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry |
type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void
/** Open today's daily note: navigate to it if it exists, or create + persist a new one. */
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn): void {
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn, vaultPath: string): void {
const date = todayDateString()
const existing = findDailyNote(entries, date)
if (existing) selectNote(existing)
else persist(resolveDailyNote(date))
else persist(resolveDailyNote(date, vaultPath))
signalFocusEditor()
}
@@ -274,7 +297,7 @@ function persistOptimistic(path: string, content: string, cbs: PersistCallbacks)
* deferred and doesn't block the tab from rendering. */
function createAndPersist(
resolved: { entry: VaultEntry; content: string },
addFn: (e: VaultEntry, c: string) => void,
addFn: (e: VaultEntry) => void,
openTab: (e: VaultEntry, c: string) => void,
cbs: PersistCallbacks,
): void {
@@ -324,7 +347,7 @@ async function runFrontmatterAndApply(
}
export function useNoteActions(config: NoteActionsConfig) {
const { addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config
const { addEntry, removeEntry, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config
const tabMgmt = useTabManagement()
const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, handleCloseTabRef, activeTabPathRef, handleSwitchTab } = tabMgmt
const tabsRef = useRef(tabMgmt.tabs)
@@ -336,8 +359,7 @@ export function useNoteActions(config: NoteActionsConfig) {
const updateTabContent = useCallback((path: string, newContent: string) => {
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
updateContent(path, newContent)
}, [setTabs, updateContent])
}, [setTabs])
const handleNavigateWikilink = useCallback(
(target: string) => navigateWikilink(entries, target, handleSelectNote),
@@ -366,22 +388,22 @@ export function useNoteActions(config: NoteActionsConfig) {
const handleCreateNote = useCallback((title: string, type: string) => {
const template = resolveTemplate(entries, type)
persistNew(resolveNewNote(title, type, template))
}, [entries, persistNew])
persistNew(resolveNewNote(title, type, config.vaultPath, template))
}, [entries, persistNew, config.vaultPath])
const handleCreateNoteImmediate = useCallback((type?: string) => {
const noteType = type || 'Note'
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
pendingNamesRef.current.add(title)
const template = resolveTemplate(entries, noteType)
const resolved = resolveNewNote(title, noteType, template)
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
config.trackUnsaved?.(resolved.entry.path)
config.markContentPending?.(resolved.entry.path, resolved.content)
signalFocusEditor({ selectTitle: true })
setTimeout(() => pendingNamesRef.current.delete(title), 500)
}, [entries, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
/** Close tab and discard entry+unsaved state if the note was never persisted. */
const handleCloseTabWithCleanup = useCallback((path: string) => {
@@ -392,17 +414,17 @@ export function useNoteActions(config: NoteActionsConfig) {
// Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes.
useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup })
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew), [entries, handleSelectNote, persistNew])
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath])
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName)), [persistNew])
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName, config.vaultPath)), [persistNew, config.vaultPath])
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
const resolved = resolveNewType(typeName)
const resolved = resolveNewType(typeName, config.vaultPath)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
await persistNewNote(resolved.entry.path, resolved.content)
return resolved.entry
}, [addEntry])
}, [addEntry, config.vaultPath])
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }
@@ -442,7 +464,30 @@ export function useNoteActions(config: NoteActionsConfig) {
handleOpenDailyNote,
handleCreateType,
createTypeEntrySilent,
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
await runFrontmatterOp('update', path, key, value)
if (isTypeKey(key) && typeof value === 'string' && value !== '') {
try {
const result = await performMoveToTypeFolder(config.vaultPath, path, value)
if (result.moved) {
const entry = entries.find(e => e.path === path)
if (entry) {
const newFilename = result.new_path.split('/').pop() ?? entry.filename
const newContent = await loadNoteContent(result.new_path)
config.replaceEntry?.(path, { ...entry, path: result.new_path, filename: newFilename })
setTabs(prev => prev.map(t => t.entry.path === path
? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: newContent }
: t))
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
}
const folder = result.new_path.split('/').slice(-2, -1)[0] ?? ''
setToastMessage(`Note moved to ${folder}/`)
}
} catch (err) {
console.error('Failed to move note to type folder:', err)
}
}
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage]),
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
handleRenameNote,

View File

@@ -175,6 +175,80 @@ describe('useNoteSearch', () => {
expect(preventDefaultSpy).not.toHaveBeenCalled()
})
it('ranks exact title match first even with many prefix competitors', () => {
const ranked: VaultEntry[] = [
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', modifiedAt: 1700000010 }),
makeEntry({ path: '/vault/rk.md', title: 'Refactoring Key Ideas', modifiedAt: 1700000009 }),
makeEntry({ path: '/vault/rp.md', title: 'Refactoring Patterns', modifiedAt: 1700000008 }),
makeEntry({ path: '/vault/rs.md', title: 'Refactoring Strategy', modifiedAt: 1700000007 }),
makeEntry({ path: '/vault/rt.md', title: 'Refactoring Techniques', modifiedAt: 1700000006 }),
makeEntry({ path: '/vault/rb.md', title: 'Refactoring Best Practices', modifiedAt: 1700000005 }),
makeEntry({ path: '/vault/rg.md', title: 'Refactoring Guide', modifiedAt: 1700000004 }),
makeEntry({ path: '/vault/rw.md', title: 'Refactoring Workflows', modifiedAt: 1700000003 }),
makeEntry({ path: '/vault/rc.md', title: 'Refactoring Checklist', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/r.md', title: 'Refactoring', isA: 'Area', modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(ranked, 'Refactoring'))
expect(result.current.results[0].title).toBe('Refactoring')
})
it('ranks exact title match above note with alias exact match', () => {
const ranked: VaultEntry[] = [
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', aliases: ['Refactoring'], modifiedAt: 1700000003 }),
makeEntry({ path: '/vault/rk.md', title: 'Refactoring Key Ideas', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/r.md', title: 'Refactoring', modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(ranked, 'Refactoring'))
expect(result.current.results[0].title).toBe('Refactoring')
})
it('ranks case-insensitive exact match first', () => {
const ranked: VaultEntry[] = [
makeEntry({ path: '/vault/qi.md', title: 'Quarter Ideas', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/q.md', title: 'Quarter', modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(ranked, 'quarter'))
expect(result.current.results[0].title).toBe('Quarter')
})
it('boosts note whose alias is an exact match', () => {
const ranked: VaultEntry[] = [
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/rn.md', title: 'Refactoring Notes', aliases: ['ref'], modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(ranked, 'ref'))
expect(result.current.results[0].title).toBe('Refactoring Notes')
})
it('excludes trashed notes from results', () => {
const withTrashed: VaultEntry[] = [
makeEntry({ path: '/vault/a.md', title: 'Active Note', modifiedAt: 1700000003 }),
makeEntry({ path: '/vault/t.md', title: 'Trashed Note', trashed: true, trashedAt: 1700000000, modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/b.md', title: 'Another Active', modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(withTrashed, ''))
expect(result.current.results.map(r => r.title)).toEqual(['Active Note', 'Another Active'])
})
it('excludes trashed notes from search results by query', () => {
const withTrashed: VaultEntry[] = [
makeEntry({ path: '/vault/a.md', title: 'Meeting Notes', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/t.md', title: 'Meeting Draft', trashed: true, modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(withTrashed, 'Meeting'))
expect(result.current.results).toHaveLength(1)
expect(result.current.results[0].title).toBe('Meeting Notes')
})
it('does not exclude archived notes from results', () => {
const withArchived: VaultEntry[] = [
makeEntry({ path: '/vault/a.md', title: 'Active Note', modifiedAt: 1700000002 }),
makeEntry({ path: '/vault/ar.md', title: 'Archived Note', archived: true, modifiedAt: 1700000001 }),
]
const { result } = renderHook(() => useNoteSearch(withArchived, ''))
expect(result.current.results).toHaveLength(2)
})
it('resolves custom type color from Type entries', () => {
const withTypes: VaultEntry[] = [
makeEntry({ path: '/vault/t/recipe.md', title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' }),

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback, useEffect } from 'react'
import type { VaultEntry } from '../types'
import { fuzzyMatch } from '../utils/fuzzyMatch'
import { fuzzyMatch, bestSearchRank } from '../utils/fuzzyMatch'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { getTypeIcon } from '../components/NoteItem'
import type { NoteSearchResultItem } from '../components/NoteSearchList'
@@ -32,7 +32,7 @@ export function useNoteSearch(entries: VaultEntry[], query: string, maxResults =
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const searchableEntries = useMemo(
() => entries.filter((e) => !SEARCH_EXCLUDED_TYPES.has(e.isA ?? '')),
() => entries.filter((e) => !e.trashed && !SEARCH_EXCLUDED_TYPES.has(e.isA ?? '')),
[entries],
)
@@ -45,9 +45,13 @@ export function useNoteSearch(entries: VaultEntry[], query: string, maxResults =
.map(mapResult)
}
return searchableEntries
.map((e) => ({ entry: e, ...fuzzyMatch(query, e.title) }))
.map((e) => ({
entry: e,
...fuzzyMatch(query, e.title),
rank: bestSearchRank(query, e.title, e.aliases),
}))
.filter((r) => r.match)
.sort((a, b) => b.score - a.score)
.sort((a, b) => a.rank - b.rank || b.score - a.score)
.slice(0, maxResults)
.map((r) => mapResult(r.entry))
}, [searchableEntries, query, maxResults, typeEntryMap])

View File

@@ -2,17 +2,16 @@ import { useMemo, useState, useCallback } from 'react'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { parseFrontmatter } from '../utils/frontmatter'
import {
type PropertyDisplayMode,
loadDisplayModeOverrides,
saveDisplayModeOverride,
removeDisplayModeOverride,
} from '../utils/propertyTypes'
import { RELATIONSHIP_KEYS, containsWikilinks } from '../components/DynamicPropertiesPanel'
import { containsWikilinks } from '../components/DynamicPropertiesPanel'
// Keys to skip showing in Properties (handled by dedicated UI or internal)
const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace', 'title', 'type', 'is_a', 'Is A'])
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'Is A'])
function coerceValue(raw: string): FrontmatterValue {
if (raw.toLowerCase() === 'true') return true
@@ -62,22 +61,17 @@ function collectVaultStatuses(entries: VaultEntry[] | undefined): string[] {
return Array.from(seen).sort((a, b) => a.localeCompare(b))
}
function mergeArrayFieldsInto(fm: ParsedFrontmatter, tagsByKey: Map<string, Set<string>>): void {
for (const [key, value] of Object.entries(fm)) {
if (!Array.isArray(value)) continue
let set = tagsByKey.get(key)
if (!set) { set = new Set(); tagsByKey.set(key, set) }
for (const tag of value) set.add(String(tag))
}
}
function collectAllVaultTags(entries: VaultEntry[] | undefined, allContent: Record<string, string> | undefined): Record<string, string[]> {
if (!entries || !allContent) return {}
function collectAllVaultTags(entries: VaultEntry[] | undefined): Record<string, string[]> {
if (!entries) return {}
const tagsByKey = new Map<string, Set<string>>()
for (const entry of entries) {
const content = allContent[entry.path]
if (!content) continue
mergeArrayFieldsInto(parseFrontmatter(content), tagsByKey)
if (!entry.properties) continue
for (const [key, value] of Object.entries(entry.properties)) {
if (!Array.isArray(value)) continue
let set = tagsByKey.get(key)
if (!set) { set = new Set(); tagsByKey.set(key, set) }
for (const tag of value) set.add(String(tag))
}
}
const result: Record<string, string[]> = {}
for (const [key, set] of tagsByKey) result[key] = Array.from(set).sort((a, b) => a.localeCompare(b))
@@ -85,7 +79,7 @@ function collectAllVaultTags(entries: VaultEntry[] | undefined, allContent: Reco
}
function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
return !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value)
return !SKIP_KEYS.has(key) && !containsWikilinks(value)
}
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
@@ -106,21 +100,20 @@ export interface PropertyPanelDeps {
entries: VaultEntry[] | undefined
entryIsA: string | null
frontmatter: ParsedFrontmatter
allContent: Record<string, string> | undefined
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
}
export function usePropertyPanelState(deps: PropertyPanelDeps) {
const { entries, entryIsA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
const { entries, entryIsA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
const [editingKey, setEditingKey] = useState<string | null>(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides())
const { availableTypes, customColorKey, typeColorKeys, typeIconKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries, allContent), [entries, allContent])
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries), [entries])
const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter])
const handleSaveValue = useCallback((key: string, newValue: string) => {

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, updateMockContent } from '../mock-tauri'
async function persistContent(path: string, content: string): Promise<void> {
export async function persistContent(path: string, content: string): Promise<void> {
if (isTauri()) {
await invoke('save_note_content', { path, content })
} else {

View File

@@ -6,7 +6,7 @@ const THEME_PATH_DEFAULT = '/vault/theme/default.md'
const THEME_PATH_DARK = '/vault/theme/dark.md'
const DEFAULT_THEME_CONTENT = `---
Is A: Theme
type: Theme
Description: Light theme
background: "#FFFFFF"
foreground: "#37352F"
@@ -19,7 +19,7 @@ text-primary: "#37352F"
`
const DARK_THEME_CONTENT = `---
Is A: Theme
type: Theme
Description: Dark theme
background: "#0f0f1a"
foreground: "#e0e0e0"
@@ -332,25 +332,6 @@ describe('useThemeManager', () => {
expect(newPath).toBe('')
})
it('re-applies theme when active content changes in allContent', async () => {
const { result, rerender } = renderHook(
({ content }) => useThemeManager('/vault', entries, content),
{ initialProps: { content: allContent } },
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const newContent = {
[THEME_PATH_DEFAULT]: `---\nIs A: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`,
}
rerender({ content: newContent })
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000')
})
})
it('reloadThemes re-reads vault settings', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
@@ -422,26 +403,6 @@ describe('useThemeManager', () => {
expect(document.documentElement.dataset.themeMode).toBe('dark')
})
it('populates theme colors from allContent when available', async () => {
const contentWithColors = {
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
[THEME_PATH_DARK]: DARK_THEME_CONTENT,
}
const { result } = renderHook(() =>
useThemeManager('/vault', entries, contentWithColors)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
const defaultTheme = result.current.themes.find(t => t.id === THEME_PATH_DEFAULT)
expect(defaultTheme?.colors.background).toBe('#FFFFFF')
expect(defaultTheme?.colors.primary).toBe('#155DFF')
const darkTheme = result.current.themes.find(t => t.id === THEME_PATH_DARK)
expect(darkTheme?.colors.background).toBe('#0f0f1a')
})
it('isDark detects dark theme from cached content', async () => {
const contentWithColors = {
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,

View File

@@ -197,8 +197,6 @@ function useThemeApplier(
export function useThemeManager(
vaultPath: string | null,
entries: VaultEntry[],
allContent: Record<string, string>,
updateContent?: (path: string, content: string) => void,
): ThemeManager {
// Ensure default theme files exist on vault open (creates theme/ dir + defaults if missing)
useEffect(() => {
@@ -206,7 +204,12 @@ export function useThemeManager(
}, [vaultPath])
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
const cachedThemeContent = activeThemeId ? allContent[activeThemeId] : undefined
const [cachedThemeContent, setCachedThemeContent] = useState<string | undefined>(undefined)
// Clear cached content when theme changes — useThemeApplier will fetch from disk
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setCachedThemeContent(undefined) }, [activeThemeId])
const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent)
// Track IDs set by user actions (switchTheme/createTheme) so the stale-ID
@@ -216,8 +219,8 @@ export function useThemeManager(
const themes = useMemo(
() => entries
.filter(e => e.isA === 'Theme' && !e.trashed && !e.archived)
.map(e => entryToThemeFile(e, allContent[e.path])),
[entries, allContent],
.map(e => entryToThemeFile(e, e.path === activeThemeId ? cachedThemeContent : undefined)),
[entries, activeThemeId, cachedThemeContent],
)
const activeTheme = useMemo(
@@ -280,9 +283,9 @@ export function useThemeManager(
key,
value,
})
updateContent?.(activeThemeId, newContent)
setCachedThemeContent(newContent)
} catch (err) { console.error('Failed to update theme property:', err) }
}, [activeThemeId, updateContent])
}, [activeThemeId])
return {
themes, activeThemeId, activeTheme,

View File

@@ -61,11 +61,10 @@ describe('useVaultLoader', () => {
mockInvokeFn.mockImplementation(defaultMockInvoke)
})
it('loads entries and content on mount', async () => {
it('loads entries on mount', async () => {
const { result } = await renderVaultLoader()
expect(result.current.entries[0].title).toBe('Hello')
expect(result.current.allContent['/vault/note/hello.md']).toContain('# Hello')
})
it('loads modified files on mount', async () => {
@@ -79,15 +78,14 @@ describe('useVaultLoader', () => {
})
describe('addEntry', () => {
it('prepends new entry and adds content', async () => {
it('prepends new entry', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New Note' }
act(() => { result.current.addEntry(newEntry, '# New Note') })
act(() => { result.current.addEntry(newEntry) })
expect(result.current.entries).toHaveLength(2)
expect(result.current.entries[0].title).toBe('New Note')
expect(result.current.allContent['/vault/note/new.md']).toBe('# New Note')
})
it('ignores duplicate entry with same path', async () => {
@@ -95,32 +93,21 @@ describe('useVaultLoader', () => {
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New Note' }
act(() => {
result.current.addEntry(newEntry, '# New Note')
result.current.addEntry(newEntry, '# New Note')
result.current.addEntry(newEntry)
result.current.addEntry(newEntry)
})
expect(result.current.entries).toHaveLength(2)
})
})
describe('updateContent', () => {
it('updates content for an existing path', async () => {
const { result } = await renderVaultLoader()
act(() => { result.current.updateContent('/vault/note/hello.md', '# Updated') })
expect(result.current.allContent['/vault/note/hello.md']).toBe('# Updated')
})
})
describe('removeEntry', () => {
it('removes entry and content by path', async () => {
it('removes entry by path', async () => {
const { result } = await renderVaultLoader()
act(() => { result.current.removeEntry('/vault/note/hello.md') })
expect(result.current.entries).toHaveLength(0)
expect(result.current.allContent['/vault/note/hello.md']).toBeUndefined()
})
it('is a no-op for non-existent paths', async () => {
@@ -159,7 +146,7 @@ describe('useVaultLoader', () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/brand-new.md', filename: 'brand-new.md', title: 'Brand New' }
act(() => { result.current.addEntry(newEntry, '# Brand New') })
act(() => { result.current.addEntry(newEntry) })
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
})
@@ -227,7 +214,7 @@ describe('useVaultLoader', () => {
}
act(() => {
result.current.addEntry(newEntry, '# New')
result.current.addEntry(newEntry)
})
expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new')
@@ -238,7 +225,7 @@ describe('useVaultLoader', () => {
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry, '# Draft')
result.current.addEntry(newEntry)
result.current.trackUnsaved('/vault/note/draft.md')
})
@@ -250,7 +237,7 @@ describe('useVaultLoader', () => {
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry, '# Draft')
result.current.addEntry(newEntry)
result.current.trackUnsaved('/vault/note/draft.md')
})
@@ -263,7 +250,7 @@ describe('useVaultLoader', () => {
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry, '# Draft')
result.current.addEntry(newEntry)
result.current.trackUnsaved('/vault/note/draft.md')
})

View File

@@ -11,8 +11,7 @@ async function loadVaultData(vaultPath: string) {
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
const entries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
console.log(`Vault scan complete: ${entries.length} entries found`)
const allContent = isTauri() ? {} : await mockInvoke<Record<string, string>>('get_all_content', { path: vaultPath })
return { entries, allContent }
return { entries }
}
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
@@ -91,7 +90,6 @@ export function resolveNoteStatus(
export function useVaultLoader(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [allContent, setAllContent] = useState<Record<string, string>>({})
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
const [modifiedFilesError, setModifiedFilesError] = useState<string | null>(null)
const tracker = useNewNoteTracker()
@@ -100,9 +98,9 @@ export function useVaultLoader(vaultPath: string) {
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
setEntries([]); setAllContent({}); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
setEntries([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
loadVaultData(vaultPath)
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
.then(({ entries: e }) => { setEntries(e) })
.catch((err) => console.warn('Vault scan failed:', err))
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable
@@ -122,31 +120,25 @@ export function useVaultLoader(vaultPath: string) {
// PERF: startTransition defers the expensive entries update (filter/sort on
// 9000+ entries) so the high-priority tab render completes in <50ms first.
const addEntry = useCallback((entry: VaultEntry, content: string) => {
const addEntry = useCallback((entry: VaultEntry) => {
startTransition(() => {
setEntries((prev) => {
if (prev.some(e => e.path === entry.path)) return prev
return [entry, ...prev]
})
setAllContent((prev) => ({ ...prev, [entry.path]: content }))
tracker.trackNew(entry.path)
})
}, [tracker])
const updateContent = useCallback((path: string, content: string) =>
setAllContent((prev) => ({ ...prev, [path]: content })), [])
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) =>
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e)), [])
const removeEntry = useCallback((path: string) => {
setEntries((prev) => prev.filter((e) => e.path !== path))
setAllContent((prev) => { const next = { ...prev }; delete next[path]; return next })
}, [])
const replaceEntry = useCallback((oldPath: string, patch: Partial<VaultEntry> & { path: string }, newContent: string) => {
const replaceEntry = useCallback((oldPath: string, patch: Partial<VaultEntry> & { path: string }) => {
setEntries((prev) => prev.map((e) => e.path === oldPath ? { ...e, ...patch } : e))
setAllContent((prev) => { const next = { ...prev }; delete next[oldPath]; next[patch.path] = newContent; return next })
}, [])
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
@@ -167,15 +159,15 @@ export function useVaultLoader(vaultPath: string) {
commitWithPush(vaultPath, message), [vaultPath])
const reloadVault = useCallback(
() => loadVaultData(vaultPath)
.then((data) => { setEntries(data.entries); setAllContent((prev) => ({ ...prev, ...data.allContent })); loadModifiedFiles(); return data.entries })
() => tauriCall<VaultEntry[]>('reload_vault', { path: vaultPath })
.then((entries) => { setEntries(entries); loadModifiedFiles(); return entries })
.catch((err) => { console.warn('Vault reload failed:', err); return [] as VaultEntry[] }),
[vaultPath, loadModifiedFiles],
)
return {
entries, allContent, modifiedFiles, modifiedFilesError,
addEntry, updateEntry, removeEntry, replaceEntry, updateContent,
entries, modifiedFiles, modifiedFilesError,
addEntry, updateEntry, removeEntry, replaceEntry,
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
getNoteStatus, commitAndPush, reloadVault,
addPendingSave: pendingSave.addPendingSave,

View File

@@ -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

@@ -734,7 +734,7 @@ rating: 5
Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing.
`,
'/Users/luca/Laputa/theme/default.md': `---
Is A: Theme
type: Theme
title: Default
primary: "#155DFF"
background: "#FFFFFF"
@@ -755,7 +755,7 @@ line-height-base: 1.6
Light theme with warm, paper-like tones.
`,
'/Users/luca/Laputa/theme/dark.md': `---
Is A: Theme
type: Theme
title: Dark
primary: "#155DFF"
background: "#0f0f1a"
@@ -776,7 +776,7 @@ line-height-base: 1.6
Dark variant with deep navy tones.
`,
'/Users/luca/Laputa/theme/minimal.md': `---
Is A: Theme
type: Theme
title: Minimal
primary: "#000000"
background: "#FAFAFA"

View File

@@ -1099,6 +1099,119 @@ export const MOCK_ENTRIES: VaultEntry[] = [
'Type': ['[[type/experiment]]'],
},
},
// --- Refactoring entries for exact-match search testing ---
{
path: '/Users/luca/Laputa/area/refactoring.md',
filename: 'refactoring.md',
title: 'Refactoring',
isA: 'Area',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 365,
fileSize: 1200,
snippet: 'Area note covering refactoring practices and principles.',
wordCount: 180,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/note/refactoring-ideas.md',
filename: 'refactoring-ideas.md',
title: 'Refactoring Ideas',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 5,
createdAt: now - 86400 * 60,
fileSize: 800,
snippet: 'Ideas for refactoring the codebase.',
wordCount: 120,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/note/refactoring-key-ideas.md',
filename: 'refactoring-key-ideas.md',
title: 'Refactoring Key Ideas',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 10,
createdAt: now - 86400 * 90,
fileSize: 600,
snippet: 'Key ideas from the refactoring book.',
wordCount: 95,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/note/refactoring-patterns.md',
filename: 'refactoring-patterns.md',
title: 'Refactoring Patterns',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 15,
createdAt: now - 86400 * 120,
fileSize: 950,
snippet: 'Common refactoring patterns and when to apply them.',
wordCount: 150,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
]
// --- Bulk entry generator for large vault testing ---

View File

@@ -150,6 +150,8 @@ const trimOrNull = (v: string | null | undefined): string | null => v?.trim() ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
export const mockHandlers: Record<string, (args: any) => any> = {
list_vault: () => MOCK_ENTRIES,
reload_vault: () => MOCK_ENTRIES,
reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, trashed: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {} },
get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '',
get_all_content: () => MOCK_CONTENT,
get_file_history: (args: { path: string }) => mockFileHistory(args.path),
@@ -219,6 +221,19 @@ export const mockHandlers: Record<string, (args: any) => any> = {
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
rename_note: handleRenameNote,
move_note_to_type_folder: (args: { vault_path: string; note_path: string; new_type: string }) => {
const slug = args.new_type.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const currentFolder = args.note_path.replace(/\/[^/]+$/, '').split('/').pop() ?? ''
if (currentFolder === slug) return { new_path: args.note_path, updated_links: 0, moved: false }
const filename = args.note_path.split('/').pop() ?? ''
const vaultPath = args.vault_path.replace(/\/$/, '')
const newPath = `${vaultPath}/${slug}/${filename}`
const content = MOCK_CONTENT[args.note_path] ?? ''
delete MOCK_CONTENT[args.note_path]
MOCK_CONTENT[newPath] = content
syncWindowContent()
return { new_path: newPath, updated_links: 0, moved: true }
},
github_list_repos: () => [
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
{ name: 'laputa-app', full_name: 'lucaong/laputa-app', description: 'Laputa desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/laputa-app.git', html_url: 'https://github.com/lucaong/laputa-app', updated_at: '2026-02-19T15:00:00Z' },
@@ -254,6 +269,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
if (!q) return { results: [], elapsed_ms: 0, query: q, mode: args.mode }
const matches = MOCK_ENTRIES
.filter(e => {
if (e.trashed) return false
const content = MOCK_CONTENT[e.path] ?? ''
return e.title.toLowerCase().includes(q) || content.toLowerCase().includes(q)
})
@@ -301,7 +317,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme'
const path = `${args.vaultPath}/theme/${slug}.md`
MOCK_CONTENT[path] = `---
Is A: Theme
type: Theme
title: ${displayName}
primary: "#155DFF"
background: "#FFFFFF"

View File

@@ -20,6 +20,7 @@ async function checkVaultApi(): Promise<boolean> {
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => string | null> = {
list_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}` : null,
reload_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` : null,
get_note_content: (args) => args.path ? `/api/vault/content?path=${encodeURIComponent(args.path as string)}` : null,
get_all_content: (args) => args.path ? `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` : null,
}

View File

@@ -46,6 +46,22 @@ export interface AgentStreamCallbacks {
onDone: () => void
}
/**
* Generate a mock response for browser/test mode.
* Inspects the message for conversation history so Playwright tests
* can verify that history is actually being sent.
*/
function mockAgentResponse(message: string): string {
if (message.includes('<conversation_history>')) {
const allUserLines = message.match(/\[user\]: .+/g) ?? []
const turnCount = allUserLines.length
const lastLine = allUserLines[allUserLines.length - 1] ?? ''
const lastUserMsg = lastLine.replace('[user]: ', '')
return `[mock-with-history turns=${turnCount}] You asked: "${lastUserMsg}" — This note is related to [[Build Laputa App]] and [[Matteo Cellini]].`
}
return `[mock-no-history] You said: "${message}" — This note is related to [[Build Laputa App]] and [[Matteo Cellini]].`
}
/**
* Stream an agent task through the Claude CLI subprocess with full tool access.
* The CLI handles the tool-use loop; we receive events for UI updates.
@@ -58,7 +74,7 @@ export async function streamClaudeAgent(
): Promise<void> {
if (!isTauri()) {
setTimeout(() => {
callbacks.onText('This note is related to [[Build Laputa App]] and [[Matteo Cellini]]. The AI Agent requires the Claude CLI for full functionality.')
callbacks.onText(mockAgentResponse(message))
callbacks.onDone()
}, 300)
return

View File

@@ -38,25 +38,23 @@ describe('buildSystemPrompt', () => {
})
it('returns empty prompt for no notes', () => {
const result = buildSystemPrompt([], {})
const result = buildSystemPrompt([])
expect(result.prompt).toBe('')
expect(result.totalTokens).toBe(0)
expect(result.truncated).toBe(false)
})
it('includes note content in the prompt', () => {
it('includes note metadata in the prompt', () => {
const notes = [makeEntry('/test.md', 'Test Note')]
const content = { '/test.md': '# Test Note\nHello world' }
const result = buildSystemPrompt(notes, content)
const result = buildSystemPrompt(notes)
expect(result.prompt).toContain('Test Note')
expect(result.prompt).toContain('Hello world')
expect(result.prompt).toContain('/test.md')
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)
const result = buildSystemPrompt(notes)
expect(result.prompt).toContain('[[')
expect(result.prompt).toMatch(/wikilink/i)
})
@@ -202,8 +200,29 @@ describe('streamClaudeChat', () => {
await new Promise(r => setTimeout(r, 400))
expect(sessionId).toBe('mock-session')
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('[mock-no-history]'))
expect(onDone).toHaveBeenCalled()
expect(onError).not.toHaveBeenCalled()
})
it('mock detects conversation history in message', async () => {
const onText = vi.fn()
const onDone = vi.fn()
const onError = vi.fn()
const msgWithHistory = formatMessageWithHistory(
[{ role: 'user', content: 'What is 2+2?', id: 'm1' }, { role: 'assistant', content: '4', id: 'm2' }],
'What was my previous question?',
)
await streamClaudeChat(msgWithHistory, undefined, undefined, {
onText, onError, onDone,
})
await new Promise(r => setTimeout(r, 400))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('[mock-with-history'))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('turns=2'))
expect(onText).toHaveBeenCalledWith(expect.stringContaining('What was my previous question?'))
})
})

View File

@@ -21,47 +21,31 @@ export function getContextLimit(): number {
// --- Context building ---
/** Build system prompt from selected context notes. */
/** Build system prompt from selected context notes (metadata only — content loaded via MCP). */
export function buildSystemPrompt(
notes: VaultEntry[],
allContent: Record<string, string>,
): { prompt: string; totalTokens: number; truncated: boolean } {
if (notes.length === 0) {
return { prompt: '', totalTokens: 0, truncated: false }
}
const contextBudget = Math.floor(getContextLimit() * 0.6)
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.',
'You can use MCP tools to read the full content of any note.',
'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')
const parts: string[] = [preamble]
let totalChars = preamble.length
let truncated = false
for (const note of notes) {
const content = allContent[note.path] ?? ''
const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) ---`
const noteText = `${header}\n${content}\n`
if (estimateTokens(totalChars + noteText.length) > contextBudget) {
const remaining = (contextBudget - estimateTokens(totalChars)) * 4
if (remaining > 200) {
parts.push(`${header}\n${content.slice(0, remaining)}\n[... truncated ...]`)
}
truncated = true
break
}
parts.push(noteText)
totalChars += noteText.length
const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) | Path: ${note.path} ---`
parts.push(header)
}
const prompt = parts.join('\n')
return { prompt, totalTokens: estimateTokens(prompt), truncated }
return { prompt, totalTokens: estimateTokens(prompt), truncated: false }
}
// --- Message types ---
@@ -164,6 +148,23 @@ function handleChatStreamEvent(
}
}
/**
* Generate a mock response for browser/test mode.
* Inspects the message for conversation history so Playwright tests
* can verify that history is actually being sent.
*/
function mockChatResponse(message: string): string {
if (message.includes('<conversation_history>')) {
const allUserLines = message.match(/\[user\]: .+/g) ?? []
const turnCount = allUserLines.length
// The last [user] line is the actual new message
const lastLine = allUserLines[allUserLines.length - 1] ?? ''
const lastUserMsg = lastLine.replace('[user]: ', '')
return `[mock-with-history turns=${turnCount}] You asked: "${lastUserMsg}"`
}
return `[mock-no-history] You said: "${message}"`
}
/**
* Stream a chat message through the Claude CLI subprocess.
* Returns the session ID for conversation continuity via --resume.
@@ -176,7 +177,7 @@ export async function streamClaudeChat(
): Promise<string> {
if (!isTauri()) {
setTimeout(() => {
callbacks.onText('AI Chat requires the Claude CLI. Install it and run the native app.')
callbacks.onText(mockChatResponse(message))
callbacks.onDone()
}, 300)
return 'mock-session'

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