Compare commits

..

71 Commits

Author SHA1 Message Date
Test
222f697873 fix: derive sidebar type sections dynamically from vault entries
Sidebar previously showed all 8 hardcoded BUILT_IN_SECTION_GROUPS regardless
of whether any notes of those types existed in the vault. Now sections are
derived from actual vault entries — only types with ≥1 active (non-trashed,
non-archived) note appear. BUILT_IN_SECTION_GROUPS is retained as metadata
lookup for icons/labels, not as the source of sections to display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:48:58 +01:00
Test
681554af58 docs: add VISION.md — core principles and long-term direction 2026-02-28 20:35:15 +01:00
Luca Rossi
87bce9d4cc fix: tags in properties — X button doesn't reserve space, fades in on hover without expanding pill (#148)
* fix: tags X button appears on hover without expanding pill width

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

* fix: tags in properties — X button doesn't reserve space, fades in on hover without expanding pill

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

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-28 20:32:58 +01:00
Test
248774ed13 style: fix rustfmt formatting in lib.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:24:35 +01:00
Test
6363e84402 fix: shift tab bar right when sidebar+notelist collapsed to avoid traffic lights overlap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:24:35 +01:00
Test
8106eb86a8 test: add spawn_ws_bridge test to push Rust coverage above 85%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:18:21 +01:00
Test
7feae25d45 docs: update architecture with full MCP server tool surface and auto-registration
- Document all 14 MCP tools with params
- Add auto-registration flow (Claude Code + Cursor configs)
- Document dual WebSocket bridge (tool port 9710, UI port 9711)
- Add Rust MCP module details (spawn, register, upsert)
- Update startup sequence with MCP initialization steps
- Add register_mcp_tools to Tauri IPC commands table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:18:21 +01:00
Test
cc08055e0a feat: MCP server foundation with full tool surface and auto-registration
- 14 MCP tools: vault CRUD, search, list, context, link, frontmatter edit, and 4 UI actions
- Auto-registers Laputa in ~/.claude/mcp.json and ~/.cursor/mcp.json on startup
- WebSocket bridge spawned on app start (port 9710 tools, 9711 UI actions)
- Frontend useMcpRegistration hook for vault-aware registration
- Comprehensive tests: 26 vault.js tests covering all 9 exported functions
- Added gray-matter dependency for frontmatter parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:18:21 +01:00
Test
c789b49bef docs: add design file for notes-type-icon-search fix
Shows search results with "Note" type icon and label displayed
consistently alongside other types like Project and Person.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:10:20 +01:00
Test
bccc0ceed4 fix: show type icon and label for "Note" type in search and autocomplete
The "Note" type was explicitly filtered out with `isA !== 'Note'` in
SearchPanel, NoteAutocomplete, and useNoteSearch, preventing its icon
and label from appearing. Remove this special-casing so all types
display consistently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:08:55 +01:00
Luca Rossi
b924ec9b95 fix: show actual type icons in Properties panel TypeSelector (#147)
* fix: parse `type` frontmatter key after is_a→type migration

The migration converts `Is A:` to `type:` in frontmatter, and the
TypeSelector UI writes to key `type`. But the Rust Frontmatter struct
only read `Is A`, so type selection was silently lost after migration
or UI change, falling back to folder inference.

Add serde alias so both `Is A` and `type` keys are parsed. Also add
`type` to SKIP_KEYS so it's not treated as a generic relationship.

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

* fix: show actual type icons in Properties panel TypeSelector

Replace generic Circle icon with each type's real icon (from
iconRegistry) in the Type dropdown. Icons resolve via getTypeIcon
with custom icon support from Type documents.

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

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:01:56 +01:00
Test
c56a8c0e7f fix: hide type note from note list, make header clickable to access it
When browsing a type section (e.g. "Book"), the type-defining note
(types/book.md) no longer appears as a PinnedCard in the note list.
Instead, the header title becomes clickable to navigate to the type note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:44:03 +01:00
Test
c5bcbecf1f ci: trigger release with GitHub Pages updater endpoint 2026-02-28 19:40:09 +01:00
Test
5ef4f2d642 fix: serve latest.json via GitHub Pages for auto-updater endpoint 2026-02-28 19:39:34 +01:00
Test
f52ce5c618 fix: expand tilde in vault paths before passing to git and file system ops
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:31:50 +01:00
Test
d83fef67f0 feat: update app icon (green cloud + home) 2026-02-28 19:26:23 +01:00
Test
8a91d25395 feat: type-aware commands in Command Palette (New/List [Type])
Add dynamic "New [Type]" and "List [Type]" commands to the Command
Palette, generated from vault entry types. Typing "new ev" fuzzy-matches
"New Event", typing "list pe" matches "List People".

- extractVaultTypes: scans vault entries for unique isA values
- buildTypeCommands: generates New/List command pairs per type
- pluralizeType: handles Person→People, Responsibility→Responsibilities
- Falls back to default types (Event, Person, Project, Note) when
  vault has no typed notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:13:29 +01:00
Test
77f76a33a4 design: command palette type-aware autocomplete mockups
Two frames showing "new ev" and "list pe" autocomplete behavior
in the Command Palette with fuzzy type matching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:06:17 +01:00
Luca Rossi
d8aa615ea3 fix: search results — type icon on left, type label aligned right (#138)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 17:33:30 +01:00
Test
82dafe9334 feat: update app icon (tree/house cloud design) 2026-02-28 14:20:32 +01:00
Test
aff56af6aa design: merge github-oauth-fix frames into ui-design.pen 2026-02-28 14:05:05 +01:00
Luca Rossi
4e2fa0d374 fix: inline GitHub OAuth device flow in Connect GitHub modal (#136)
* fix: inline GitHub OAuth device flow in Connect GitHub modal

Previously, clicking "Connect GitHub repo" without a token would redirect
to Settings, forcing the user to authenticate there and then navigate back.
Now the device flow runs directly inside the GitHubVaultModal, showing the
user code and opening the browser inline.

- Extract GitHubDeviceFlow component from SettingsPanel (shared by both)
- Add onGitHubConnected prop to GitHubVaultModal for inline auth
- Update mock defaults to no-token state for testable device flow
- Add tests for inline device flow in modal

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

* design: add GitHub OAuth fix wireframes

Three frames showing the fixed modal states:
1. Login button (inline device flow start)
2. Device code waiting (code + URL + spinner)
3. Error/expired state with retry

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

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:02:38 +01:00
Luca Rossi
465a80d3e1 style: cargo fmt (#135)
* ci: re-enable macOS code signing and notarization

Uncomment the Apple credential env vars in the release workflow
so Tauri's build step signs and notarizes the .app bundle.
This reverses the temporary disable from ee42731.

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

* style: cargo fmt

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:33:09 +01:00
Test
ce12e3cd6f fix: use Tauri opener plugin for GitHub OAuth browser launch
Replace window.open() with openExternalUrl() which uses
@tauri-apps/plugin-opener in native mode. Also show the verification
URL in the waiting UI so users can navigate manually, add a copy-code
button, and display a retry button on error/expiry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:13:42 +01:00
Test
e67eb5e85e design: add GitHub OAuth device code UI frames
Two frames showing the device code waiting state (code + copy button +
clickable URL + spinner) and the error state with retry button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:13:42 +01:00
Test
c11184b24a ci: trigger release with correct Developer ID certificate 2026-02-28 13:11:00 +01:00
Test
ee42731ecc ci: temporarily disable notarization (awaiting new certificate) 2026-02-28 13:01:02 +01:00
Test
3c57b0ff1a feat: integrate welcome screen with tests and fix App tests
WelcomeScreen component (14 tests):
- welcome mode: title, buttons, hint, loading, error states
- vault-missing mode: path badge, different button labels

useOnboarding hook (10 tests):
- vault exists → ready, vault missing → welcome
- dismissed + missing → vault-missing
- create vault, open folder, dismiss transitions
- error handling, picker cancellation, command failure fallback

App.test.tsx updated to mock new Tauri commands
(check_vault_exists, get_default_vault_path).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:58:21 +01:00
Test
bd0576c593 feat: add Rust backend for Getting Started vault creation
New Tauri commands:
- create_getting_started_vault: creates sample vault with 11 files
  (4 types, 4 notes, 1 project, 1 person, 1 topic)
- check_vault_exists: validates if a vault path exists on disk
- get_default_vault_path: returns ~/Documents/Laputa

Sample notes demonstrate editor formatting, properties, wiki-links,
and relationships — covering all key Laputa features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:57:34 +01:00
Test
57f0378bab docs: add design file for Getting Started vault welcome screens
Two frames: Welcome Screen (first launch) and Vault Missing Error
(vault path configured but folder deleted). Both follow Laputa design
system colors, spacing, and typography.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:57:34 +01:00
Test
1ff342ae60 design: merge tags-property-type + zoom-shortcuts frames into ui-design.pen 2026-02-28 12:44:18 +01:00
Test
da55318979 fix: useRef type for debounce timer (TS 5.9 compatibility) 2026-02-28 12:43:18 +01:00
Luca Rossi
384cffec48 feat: zoom in/out keyboard shortcuts (Cmd+=, Cmd+-, Cmd+0) (#134)
* feat: zoom in/out keyboard shortcuts (Cmd+=, Cmd+-, Cmd+0)

Add zoom control with keyboard shortcuts, native menu items, command
palette integration, and StatusBar zoom indicator with localStorage
persistence (80-150%, 10% step).

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

* docs: add zoom-shortcuts design file with StatusBar zoom indicator

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

* design: zoom-shortcuts — StatusBar zoom indicator + Command Palette entries

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:41:57 +01:00
Test
c0ce5064d8 fix: use number | undefined for debounce timer ref (TS strict compat) 2026-02-28 12:38:23 +01:00
Luca Rossi
e9c869075a feat: add tags (multi-select) property type (#133)
* design: add tags property type wireframes

Three frames showing the Tags multi-select property:
- Display state: colored pills with X to remove, + button to add
- Input state: dropdown with vault suggestions, checkmarks for selected
- Color picker: per-tag color selection row with accent palette

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

* feat: add tags (multi-select) property type

Adds a new 'tags' display mode for array-valued frontmatter properties.
Includes TagPill components with deterministic color assignment,
inline tag editing with autocomplete from vault-wide values, and
automatic detection for common tag key patterns (tags, keywords,
categories, labels).

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

* fix: SearchPanel test — fire ArrowDown on document not window

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:34:01 +01:00
Test
f881c13b62 design: merge relationship-x-cosmetic frames into ui-design.pen 2026-02-28 12:09:35 +01:00
Luca Rossi
92c7b003d9 fix: useRef type for debounce timer (pre-existing build error on main) (#132)
* design: relationship-x-cosmetic wireframes

* fix: relationship X button appears inline inside pill on hover

X button now renders as a flex child inside the pill (next to the type icon)
instead of being absolutely positioned outside the pill boundary.

- Pill uses inset ring border on hover when bgColor is set
- X appears inline with type icon via flex layout
- Type icon rendered at 50% opacity per design spec
- Updated test selector: .group/link is now the button element itself

* fix: useRef type for debounce timer (pre-existing build error on main)

---------

Co-authored-by: Test <test@test.com>
2026-02-28 12:07:35 +01:00
Test
cafdc84260 fix: remove invalid notarization field from tauri.conf.json (Tauri v2 uses env vars) 2026-02-28 12:03:02 +01:00
Test
d87c80ece4 ci: fix notarization — use Tauri v2 env vars (APPLE_CERTIFICATE + APPLE_SIGNING_IDENTITY) 2026-02-28 11:49:51 +01:00
Test
a67e0bd221 ci: trigger release with notarization 2026-02-28 11:32:17 +01:00
Test
ed8a51aa8f ci: add Apple notarization to release workflow (awaiting certificate) 2026-02-28 11:29:57 +01:00
Test
fb763f5338 feat: sync note title with H1 heading (predictive title)
When the editor's first block is an H1 heading, the note title
automatically stays in sync. Changes are debounced at 500ms.
Manual rename via tab breaks the sync until the tab is switched.

- Show H1 in editor (stop stripping on load, stop reconstructing on save)
- New useHeadingTitleSync hook tracks sync state and debounces title updates
- handleEditorChange keeps frontmatter title: in sync with H1
- Fast path for H1-only content preserves instant new-note interactivity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:59:05 +01:00
Test
a18a19166b fix: property panel responsive layout + status dropdown click regression
- StatusDropdown: change anchor element from <div> to <span> to fix
  invalid HTML nesting inside <span> parents. Browsers auto-correct
  <div> inside <span> by ripping the element out, breaking
  anchorRef.parentElement positioning. JSDOM doesn't auto-correct,
  so tests passed but the real app's dropdown failed to position.

- StatusValue: revert shrink-0 back to min-w-0 so the status pill
  can truncate in narrow panels instead of overflowing.

- PropertyRow: add min-w-0 and gap-2 for proper flex overflow,
  wrap property key in truncate span.

- AddPropertyForm: add flex-wrap, reduce input widths, set
  min-w-[60px] on value inputs for narrow panel support.

- InfoRow/TypeSelector/ReadOnlyType: add min-w-0 and gap-2.

- Tests: increase timeout for 3 Radix Select interaction tests
  that are slow in JSDOM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:58:23 +01:00
Test
1efdbfb978 fix: properties panel bar height matches breadcrumb bar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:56:14 +01:00
Test
233497cb5c fix: search results — type icon on left, type label aligned right
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:55:08 +01:00
Test
6ff3a1929a fix: reverse relationships — remove header, arrow prefix, dotted underline on hover
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:55:03 +01:00
Test
42b463e07a fix: type selector dropdown shows colored icons per type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:54:37 +01:00
Test
a6b92d5248 fix: relationship item input height consistency + X button overlay on hover
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:54:09 +01:00
Test
8c574882e5 fix: tags X button appears on hover without expanding pill width
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:50:34 +01:00
Test
bb0a5c5c98 fix: make new note editor immediately interactive
Extract heading-stripping logic into `extractEditorBody` — fixes a bug
where the regex failed to strip the title heading from newly created
notes (because `splitFrontmatter` left a leading newline that the
regex didn't account for).

Add a fast path in `doSwap` for empty body content: when the body is
empty (common case for new notes), skip the potentially-async
`tryParseMarkdownToBlocks` pipeline entirely and apply a single empty
paragraph block synchronously. This eliminates the multi-second delay
caused by BlockNote's async markdown parser initialization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:29:28 +01:00
Test
cd1246d43c fix: multiselect uses only Shift+click; add Cmd+Del/Cmd+E bulk actions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:49:29 +01:00
Test
92c8dbd1bf fix: restore status dropdown click + color picker per status value
The positionDropdown callback ref ran during React's commit phase
before anchorRef was assigned (children refs commit before parents),
so the dropdown was never positioned and the invisible backdrop
stole all subsequent clicks. Switch to useLayoutEffect which runs
after all refs are set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:49:01 +01:00
Test
4672fcb5c0 fix: add traffic light clearance to note list header when sidebar is collapsed
When the sidebar is hidden (editor-list view mode), the NoteList becomes
the leftmost panel and its header title overlaps with macOS traffic lights.
Add 80px left padding to the NoteList header when sidebarCollapsed is true,
matching the sidebar title bar's existing traffic light clearance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:43:19 +01:00
Test
d962cd9eae fix: update Tauri signing pubkey (new keypair, password stored securely) 2026-02-27 21:53:47 +01:00
Test
88a8035c45 fix: update Tauri signing pubkey to match new keypair (empty password) 2026-02-27 21:42:14 +01:00
Test
035bd7f630 fix: update Tauri signing pubkey (regenerated keypair with empty password) 2026-02-27 21:26:19 +01:00
Test
9ce890576b fix: use TAURI_KEY_PASSWORD secret for signing (was hardcoded empty string) 2026-02-27 21:14:42 +01:00
Test
f569750666 chore: add .claude-done for pencil-ui-design-light-mode verification
Verified all 115 frames in ui-design.pen render in light mode.
No remaining before-variants, no duplicates found.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:06:43 +01:00
Test
9cfa8a7ca2 design: add theme Light to 25 dark-mode frames, remove before variants 2026-02-27 19:06:38 +01:00
Test
918b204d3d design: fix search panel overlay layout positioning 2026-02-27 19:05:16 +01:00
Luca Rossi
177e593e90 fix: use portal-based positioning for property panel dropdowns (#130)
* fix: use portal-based positioning for property panel dropdowns

StatusDropdown and DisplayModeSelector used absolute positioning
within the Inspector's overflow-hidden container, causing dropdowns
to be clipped when the Properties panel is narrow (200-280px).

Both now render via createPortal into document.body with fixed
positioning calculated from the trigger element's bounding rect.
This matches the pattern used by existing Radix UI components
(Select, Popover) in the codebase.

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

* design: property dropdown narrow panel fix — before/after frames

---------

Co-authored-by: Laputa App <laputa@app.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:00:30 +01:00
Laputa App
959e4975e3 design: update new-note-creation.pen for unsaved state
Update tab indicator to blue dot (was green), add italic font
style to unsaved tab title, rename frame to match new behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:23:28 +01:00
Laputa App
a2f1476b98 feat: in-memory unsaved state for new notes (Cmd+N)
New notes created via Cmd+N now stay in memory until the user
explicitly saves with Cmd+S. This eliminates the disk-write
blocking delay and lets the cursor appear instantly.

- Add 'unsaved' to NoteStatus; blue dot + italic title in tab bar
- useUnsavedTracker in useVaultLoader manages unsaved path set
- useNoteActions skips persist on create, cleans up on close
- useEditorSave accepts unsavedFallback for first-save scenario
- App.tsx wires tracking via contentChangeRef + clearUnsaved

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:23:28 +01:00
Laputa App
9d5c90f5c0 feat: replace search result snippet with metadata subtitle
Replace the first-line-of-content snippet in search results with
a metadata row showing: relative date, created date (when different
from modified), word count, and outgoing link count.

Uses the existing formatSearchSubtitle() helper. Entry lookup now
stores full VaultEntry for metadata access. Graceful degradation
when fields are missing/zero.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:06:49 +01:00
Laputa App
e91a7ae7f6 design: search results subtitle with metadata frames
Two frames showing the redesigned search result subtitle:
- Main view with metadata (date, word count, links) replacing snippet
- States catalog: all metadata, same dates, no links, empty, no date

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:03:41 +01:00
Laputa App
47e38ba6b0 chore: add src-tauri/target to .gitignore 2026-02-27 17:32:07 +01:00
Laputa App
cbb88b34b8 fix: remove accidental src-tauri/target symlink from repo
Was accidentally committed as a circular symlink in previous commit.
Cargo recreates this directory automatically on build.
2026-02-27 17:31:56 +01:00
Laputa App
52b1693f43 fix: property dropdowns adapt to narrow panel width
- Popover and Select components: add collisionPadding=8 as default
- TypeSelector and AddPropertyForm SelectContent: position=popper, side=left
- DateValue and AddDateInput PopoverContent: side=left
Dropdowns now flip direction when hitting viewport edge instead of clipping.
2026-02-27 16:58:10 +01:00
Luca Rossi
3d784ce740 fix: unify status dropdown and make color swatch visible (#128)
Bug 1: Replace plain-text Radix Select in AddStatusInput with the same
StatusDropdown component used for existing status properties. Both now
show colored pills, search, and custom status creation.

Bug 2: Add color swatch dot next to each status option in the dropdown.
Clicking it opens an inline palette of 8 accent colors that persist via
localStorage (leveraging existing setStatusColor infrastructure).

Refactor StatusDropdown to extract useStatusFiltering, useStatusKeyboard
hooks and VaultSection/SuggestedSection/CreateSection sub-components to
keep Code Health above 9.2 threshold.

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:53:31 +01:00
Luca Rossi
480d124a0e fix: multiselect range includes anchor note and prevents text selection (#127)
- selectRange() now includes the currently open note as the anchor
- Added setAnchor() to track which note is the selection start
- Added select-none to NoteList to prevent browser text selection on Shift+click

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:53:18 +01:00
lucaronin
b15a4d143c fix: raise property type dropdown z-index above BlockNote editor
BlockNote uses z-index: 11000 for its toolbar/popover elements.
The Radix UI Select and Popover portals used z-50 (z-index: 50),
causing all inspector dropdowns (type selector, property type picker,
date pickers) to render behind the editor. Raised to z-[12000] to
match the existing StatusDropdown and DisplayModeSelector z-index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:37:56 +01:00
105 changed files with 47094 additions and 887 deletions

View File

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

View File

@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVCQTVDRkIzNkFGRTYwQjUKUldTMVlQNXFzOCtsVzROZmJLR0JFVGw1a1UzKzViY3dUcWFoaUttRFhhVk8rVUhrc29QL1FPeXUK

View File

@@ -1,36 +1,3 @@
task: refactor-editor-notelist-sidebar
ui-change: no
## Summary
Refactored three CodeScene hotspot files to improve code health scores:
| File | Before | After | Approach |
|------|--------|-------|----------|
| Editor.tsx | 7.82 (cc=35) | 10.0 | Decomposed into EditorContent, EditorRightPanel, SingleEditorView, editorSchema, useDiffMode, useEditorFocus, suggestionEnrichment |
| NoteList.tsx | 9.04 (cc=13) | 9.38 | Extracted createNoteStatusResolver, toggleSetMember standalone functions |
| Sidebar.tsx | 9.09 (cc=10) | 10.0 | Extracted applyCustomization standalone function |
## New files
- `src/components/EditorContent.tsx` — breadcrumb bar + editor/diff view switching
- `src/components/EditorRightPanel.tsx` — AI chat / inspector panel toggle
- `src/components/SingleEditorView.tsx` — BlockNote editor with suggestion menus
- `src/components/editorSchema.tsx` — WikiLink inline content spec + BlockNote schema
- `src/hooks/useDiffMode.ts` — diff mode state management
- `src/hooks/useEditorFocus.ts` — editor focus on custom event
- `src/utils/suggestionEnrichment.ts` — shared suggestion item enrichment pipeline
## New tests (20 tests)
- `src/hooks/useDiffMode.test.ts` — 10 tests
- `src/hooks/useEditorFocus.test.ts` — 3 tests
- `src/utils/suggestionEnrichment.test.ts` — 7 tests
## Validation
- 976 tests pass (956 existing + 20 new)
- 80% frontend coverage (≥70% required)
- 270 Rust tests pass
- CodeScene quality gates: passed
- Zero behavior change — pure refactor
Task: pencil-ui-design-light-mode
Summary: Verified all 115 top-level frames in ui-design.pen have theme:{Mode:Light}. No remaining before-variant frames found (only legitimate state-transition labels). No duplicate frames detected. Visual screenshots confirm correct light-mode rendering. Previous commit 165cc0e already applied theme to 25 dark-mode frames and removed 2 before variants.
Commits: 1 (existing)

View File

@@ -80,10 +80,16 @@ jobs:
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Build Tauri app (with signing)
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ""
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
pnpm tauri build --target ${{ matrix.target }}
@@ -196,6 +202,8 @@ jobs:
run: |
mkdir -p _site
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
# Copy latest.json to GitHub Pages for auto-updater endpoint
gh release download --repo ${{ github.repository }} --pattern "latest.json" --output _site/latest.json || true
cat > _site/index.html << 'HTMLEOF'
<!DOCTYPE html>
<html lang="en">

1
.gitignore vendored
View File

@@ -41,3 +41,4 @@ final_selection.py
# Claude Code task signals
.claude-done
.claude-blocked
src-tauri/target

View File

@@ -79,11 +79,14 @@ if [ "$RUST_CHANGED" = true ]; then
else
echo "🦀 [3/4] Rust coverage (≥85%, incremental)..."
fi
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
# shellcheck disable=SC2086
cargo llvm-cov \
--manifest-path src-tauri/Cargo.toml \
$LLVM_COV_FLAGS \
--fail-under-lines 85
--fail-under-lines 85 \
-- --test-threads=1
echo " ✅ Rust coverage OK"
else
echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)"

53
VISION.md Normal file
View File

@@ -0,0 +1,53 @@
# 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

@@ -0,0 +1,387 @@
{
"children": [
{
"type": "frame",
"id": "cp_new_ev",
"name": "Command Palette — type \"new ev\" autocomplete",
"x": 0,
"y": 0,
"width": 620,
"height": "fit_content(500)",
"fill": "$--background",
"layout": "vertical",
"gap": 24,
"padding": [32, 32],
"theme": { "Mode": "Light" },
"children": [
{
"type": "text",
"id": "cp_new_title",
"content": "Command Palette — \"new ev\" autocomplete",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
},
{
"type": "frame",
"id": "cp_new_dialog",
"name": "Palette Dialog",
"width": 520,
"height": "fit_content(440)",
"fill": "$--popover",
"layout": "vertical",
"cornerRadius": [12, 12, 12, 12],
"stroke": "$--border",
"strokeThickness": 1,
"children": [
{
"type": "frame",
"id": "cp_new_input_row",
"width": "fill_container",
"height": 48,
"padding": [0, 16],
"layout": "horizontal",
"verticalAlign": "center",
"stroke": "$--border",
"strokeSides": ["bottom"],
"strokeThickness": 1,
"children": [
{
"type": "text",
"id": "cp_new_query",
"content": "new ev",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 15
}
]
},
{
"type": "frame",
"id": "cp_new_results",
"name": "Results",
"width": "fill_container",
"layout": "vertical",
"padding": [4, 0],
"children": [
{
"type": "text",
"id": "cp_new_group_label",
"content": "NOTE",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "600",
"letterSpacing": 1,
"padding": [8, 16, 4, 16]
},
{
"type": "frame",
"id": "cp_new_row1",
"name": "New Event (selected)",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"layout": "horizontal",
"verticalAlign": "center",
"horizontalAlign": "spaceBetween",
"cornerRadius": [6, 6, 6, 6],
"fill": "$--accent",
"margin": [0, 4],
"children": [
{
"type": "text",
"id": "cp_new_row1_label",
"content": "New Event",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 14
}
]
},
{
"type": "frame",
"id": "cp_new_row2",
"name": "New Event Log",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"layout": "horizontal",
"verticalAlign": "center",
"horizontalAlign": "spaceBetween",
"cornerRadius": [6, 6, 6, 6],
"margin": [0, 4],
"children": [
{
"type": "text",
"id": "cp_new_row2_label",
"content": "New Event Log",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 14
}
]
},
{
"type": "frame",
"id": "cp_new_row3",
"name": "New Experiment",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"layout": "horizontal",
"verticalAlign": "center",
"horizontalAlign": "spaceBetween",
"cornerRadius": [6, 6, 6, 6],
"margin": [0, 4],
"children": [
{
"type": "text",
"id": "cp_new_row3_label",
"content": "New Experiment",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 14
}
]
}
]
},
{
"type": "frame",
"id": "cp_new_footer",
"width": "fill_container",
"height": 32,
"padding": [0, 16],
"layout": "horizontal",
"verticalAlign": "center",
"gap": 16,
"stroke": "$--border",
"strokeSides": ["top"],
"strokeThickness": 1,
"children": [
{
"type": "text",
"id": "cp_new_hint1",
"content": "↑↓ navigate",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "cp_new_hint2",
"content": "↵ select",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "cp_new_hint3",
"content": "esc close",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11
}
]
}
]
}
]
},
{
"type": "frame",
"id": "cp_list_pe",
"name": "Command Palette — type \"list pe\" autocomplete",
"x": 720,
"y": 0,
"width": 620,
"height": "fit_content(500)",
"fill": "$--background",
"layout": "vertical",
"gap": 24,
"padding": [32, 32],
"theme": { "Mode": "Light" },
"children": [
{
"type": "text",
"id": "cp_list_title",
"content": "Command Palette — \"list pe\" autocomplete",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
},
{
"type": "frame",
"id": "cp_list_dialog",
"name": "Palette Dialog",
"width": 520,
"height": "fit_content(440)",
"fill": "$--popover",
"layout": "vertical",
"cornerRadius": [12, 12, 12, 12],
"stroke": "$--border",
"strokeThickness": 1,
"children": [
{
"type": "frame",
"id": "cp_list_input_row",
"width": "fill_container",
"height": 48,
"padding": [0, 16],
"layout": "horizontal",
"verticalAlign": "center",
"stroke": "$--border",
"strokeSides": ["bottom"],
"strokeThickness": 1,
"children": [
{
"type": "text",
"id": "cp_list_query",
"content": "list pe",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 15
}
]
},
{
"type": "frame",
"id": "cp_list_results",
"name": "Results",
"width": "fill_container",
"layout": "vertical",
"padding": [4, 0],
"children": [
{
"type": "text",
"id": "cp_list_group_label",
"content": "NAVIGATION",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "600",
"letterSpacing": 1,
"padding": [8, 16, 4, 16]
},
{
"type": "frame",
"id": "cp_list_row1",
"name": "List People (selected)",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"layout": "horizontal",
"verticalAlign": "center",
"horizontalAlign": "spaceBetween",
"cornerRadius": [6, 6, 6, 6],
"fill": "$--accent",
"margin": [0, 4],
"children": [
{
"type": "text",
"id": "cp_list_row1_label",
"content": "List People",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 14
}
]
},
{
"type": "frame",
"id": "cp_list_row2",
"name": "List Procedures",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"layout": "horizontal",
"verticalAlign": "center",
"horizontalAlign": "spaceBetween",
"cornerRadius": [6, 6, 6, 6],
"margin": [0, 4],
"children": [
{
"type": "text",
"id": "cp_list_row2_label",
"content": "List Procedures",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 14
}
]
},
{
"type": "frame",
"id": "cp_list_row3",
"name": "List Projects",
"width": "fill_container",
"height": 36,
"padding": [0, 12],
"layout": "horizontal",
"verticalAlign": "center",
"horizontalAlign": "spaceBetween",
"cornerRadius": [6, 6, 6, 6],
"margin": [0, 4],
"children": [
{
"type": "text",
"id": "cp_list_row3_label",
"content": "List Projects",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 14
}
]
}
]
},
{
"type": "frame",
"id": "cp_list_footer",
"width": "fill_container",
"height": 32,
"padding": [0, 16],
"layout": "horizontal",
"verticalAlign": "center",
"gap": 16,
"stroke": "$--border",
"strokeSides": ["top"],
"strokeThickness": 1,
"children": [
{
"type": "text",
"id": "cp_list_hint1",
"content": "↑↓ navigate",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "cp_list_hint2",
"content": "↵ select",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "cp_list_hint3",
"content": "esc close",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11
}
]
}
]
}
]
}
],
"variables": {}
}

View File

@@ -0,0 +1,56 @@
{
"children": [
{
"type": "frame",
"id": "fpdn_before",
"name": "Property Dropdown Narrow — Before (clipped)",
"x": 0,
"y": 0,
"width": 280,
"height": 200,
"fill": "#1a1a1a",
"layout": "vertical",
"gap": 8,
"padding": 12,
"theme": "dark",
"children": [
{
"type": "text",
"id": "fpdn_before_label",
"content": "Bug: StatusDropdown clipped by overflow:hidden container at 200-280px panel width",
"fill": "#ff4444",
"fontFamily": "SF Pro Text",
"fontSize": 12,
"fontWeight": "regular",
"letterSpacing": 0
}
]
},
{
"type": "frame",
"id": "fpdn_after",
"name": "Property Dropdown Narrow — After (portal, no clip)",
"x": 320,
"y": 0,
"width": 280,
"height": 200,
"fill": "#1a1a1a",
"layout": "vertical",
"gap": 8,
"padding": 12,
"theme": "dark",
"children": [
{
"type": "text",
"id": "fpdn_after_label",
"content": "Fix: StatusDropdown + DisplayModeSelector use createPortal with fixed positioning — renders above overflow:hidden, visible at any panel width",
"fill": "#44bb44",
"fontFamily": "SF Pro Text",
"fontSize": 12,
"fontWeight": "regular",
"letterSpacing": 0
}
]
}
]
}

View File

@@ -0,0 +1,381 @@
{
"children": [
{
"type": "frame",
"id": "welcome_screen",
"name": "Getting Started — Welcome Screen",
"x": 0,
"y": 0,
"width": 1440,
"height": 900,
"fill": "#F7F6F3",
"layout": "vertical",
"alignItems": "center",
"justifyContent": "center",
"gap": 0,
"theme": { "Mode": "Light" },
"children": [
{
"type": "frame",
"id": "welcome_card",
"name": "Welcome Card",
"width": 520,
"height": "fit_content",
"fill": "#FFFFFF",
"cornerRadius": [12, 12, 12, 12],
"layout": "vertical",
"alignItems": "center",
"gap": 24,
"padding": [48, 48, 48, 48],
"stroke": "#E9E9E7",
"strokeThickness": 1,
"children": [
{
"type": "frame",
"id": "welcome_icon_wrap",
"name": "Icon",
"width": 64,
"height": 64,
"fill": "#EBF4FF",
"cornerRadius": [16, 16, 16, 16],
"layout": "vertical",
"alignItems": "center",
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "welcome_icon_text",
"content": "✦",
"fontSize": 28,
"fill": "#2383E2",
"fontFamily": "Inter"
}
]
},
{
"type": "frame",
"id": "welcome_text_group",
"name": "Text Group",
"layout": "vertical",
"alignItems": "center",
"gap": 8,
"width": "fill_container",
"children": [
{
"type": "text",
"id": "welcome_title",
"content": "Welcome to Laputa",
"fill": "#37352F",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": "700",
"letterSpacing": -0.5,
"textAlign": "center"
},
{
"type": "text",
"id": "welcome_subtitle",
"content": "Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.",
"fill": "#787774",
"fontFamily": "Inter",
"fontSize": 14,
"lineHeight": 1.6,
"textAlign": "center"
}
]
},
{
"type": "rectangle",
"id": "welcome_divider",
"width": "fill_container",
"height": 1,
"fill": "#E9E9E7"
},
{
"type": "frame",
"id": "welcome_actions",
"name": "Actions",
"layout": "vertical",
"gap": 12,
"width": "fill_container",
"children": [
{
"type": "frame",
"id": "btn_create_vault",
"name": "Create Getting Started Vault Button",
"width": "fill_container",
"height": 44,
"fill": "#2383E2",
"cornerRadius": [8, 8, 8, 8],
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"gap": 8,
"children": [
{
"type": "text",
"id": "btn_create_icon",
"content": "",
"fill": "#FFFFFF",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
},
{
"type": "text",
"id": "btn_create_label",
"content": "Create Getting Started vault",
"fill": "#FFFFFF",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "btn_open_folder",
"name": "Open Existing Folder Button",
"width": "fill_container",
"height": 44,
"fill": "#FFFFFF",
"stroke": "#E9E9E7",
"strokeThickness": 1,
"cornerRadius": [8, 8, 8, 8],
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"gap": 8,
"children": [
{
"type": "text",
"id": "btn_open_icon",
"content": "📂",
"fill": "#37352F",
"fontFamily": "Inter",
"fontSize": 14
},
{
"type": "text",
"id": "btn_open_label",
"content": "Open an existing folder",
"fill": "#37352F",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
}
]
}
]
},
{
"type": "text",
"id": "welcome_hint",
"content": "The Getting Started vault will be created in ~/Documents/Laputa",
"fill": "#787774",
"fontFamily": "Inter",
"fontSize": 12,
"textAlign": "center"
}
]
}
]
},
{
"type": "frame",
"id": "vault_missing_screen",
"name": "Getting Started — Vault Missing Error",
"x": 1540,
"y": 0,
"width": 1440,
"height": 900,
"fill": "#F7F6F3",
"layout": "vertical",
"alignItems": "center",
"justifyContent": "center",
"gap": 0,
"theme": { "Mode": "Light" },
"children": [
{
"type": "frame",
"id": "missing_card",
"name": "Missing Vault Card",
"width": 520,
"height": "fit_content",
"fill": "#FFFFFF",
"cornerRadius": [12, 12, 12, 12],
"layout": "vertical",
"alignItems": "center",
"gap": 24,
"padding": [48, 48, 48, 48],
"stroke": "#E9E9E7",
"strokeThickness": 1,
"children": [
{
"type": "frame",
"id": "missing_icon_wrap",
"name": "Warning Icon",
"width": 64,
"height": 64,
"fill": "#FFF3E0",
"cornerRadius": [16, 16, 16, 16],
"layout": "vertical",
"alignItems": "center",
"justifyContent": "center",
"children": [
{
"type": "text",
"id": "missing_icon_text",
"content": "⚠",
"fontSize": 28,
"fill": "#E8890C",
"fontFamily": "Inter"
}
]
},
{
"type": "frame",
"id": "missing_text_group",
"name": "Text Group",
"layout": "vertical",
"alignItems": "center",
"gap": 8,
"width": "fill_container",
"children": [
{
"type": "text",
"id": "missing_title",
"content": "Vault not found",
"fill": "#37352F",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": "700",
"letterSpacing": -0.5,
"textAlign": "center"
},
{
"type": "text",
"id": "missing_subtitle",
"content": "The vault folder could not be found on disk.\nIt may have been moved or deleted.",
"fill": "#787774",
"fontFamily": "Inter",
"fontSize": 14,
"lineHeight": 1.6,
"textAlign": "center"
}
]
},
{
"type": "frame",
"id": "missing_path_badge",
"name": "Path Badge",
"width": "fill_container",
"height": "fit_content",
"fill": "#F7F6F3",
"cornerRadius": [6, 6, 6, 6],
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"padding": [8, 12, 8, 12],
"children": [
{
"type": "text",
"id": "missing_path_text",
"content": "~/Laputa",
"fill": "#787774",
"fontFamily": "SF Mono, monospace",
"fontSize": 12
}
]
},
{
"type": "rectangle",
"id": "missing_divider",
"width": "fill_container",
"height": 1,
"fill": "#E9E9E7"
},
{
"type": "frame",
"id": "missing_actions",
"name": "Actions",
"layout": "vertical",
"gap": 12,
"width": "fill_container",
"children": [
{
"type": "frame",
"id": "btn_recreate_vault",
"name": "Create Getting Started Vault Button",
"width": "fill_container",
"height": 44,
"fill": "#2383E2",
"cornerRadius": [8, 8, 8, 8],
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"gap": 8,
"children": [
{
"type": "text",
"id": "btn_recreate_icon",
"content": "",
"fill": "#FFFFFF",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
},
{
"type": "text",
"id": "btn_recreate_label",
"content": "Create Getting Started vault",
"fill": "#FFFFFF",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
}
]
},
{
"type": "frame",
"id": "btn_choose_folder",
"name": "Choose Different Folder Button",
"width": "fill_container",
"height": 44,
"fill": "#FFFFFF",
"stroke": "#E9E9E7",
"strokeThickness": 1,
"cornerRadius": [8, 8, 8, 8],
"layout": "horizontal",
"alignItems": "center",
"justifyContent": "center",
"gap": 8,
"children": [
{
"type": "text",
"id": "btn_choose_icon",
"content": "📂",
"fill": "#37352F",
"fontFamily": "Inter",
"fontSize": 14
},
{
"type": "text",
"id": "btn_choose_label",
"content": "Choose a different folder",
"fill": "#37352F",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "500"
}
]
}
]
}
]
}
]
}
],
"variables": {}
}

258
design/github-oauth-fix.pen Normal file
View File

@@ -0,0 +1,258 @@
{
"children": [
{
"type": "frame",
"id": "gofix01",
"x": 0,
"y": 0,
"name": "GitHub OAuth Fix — Modal with inline device flow",
"clip": true,
"width": 560,
"height": 420,
"fill": "$--background",
"cornerRadius": 12,
"layout": "vertical",
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "gofix01-hdr",
"name": "Header",
"width": "fill_container",
"height": 56,
"fill": "$--background",
"padding": [0, 24],
"alignItems": "center",
"justifyContent": "space-between",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "text", "id": "gofix01-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
{ "type": "frame", "id": "gofix01-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix01-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
]
},
{
"type": "frame",
"id": "gofix01-desc",
"name": "Description",
"width": "fill_container",
"padding": [16, 24],
"children": [
{ "type": "text", "id": "gofix01-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
]
},
{
"type": "frame",
"id": "gofix01-body",
"name": "DeviceFlowLogin",
"width": "fill_container",
"layout": "vertical",
"gap": 16,
"padding": [24, 24],
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "gofix01-login-btn",
"name": "LoginButton",
"width": 200,
"height": 36,
"fill": "$--foreground",
"cornerRadius": 6,
"alignItems": "center",
"justifyContent": "center",
"gap": 8,
"children": [
{ "type": "text", "id": "gofix01-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" },
{ "type": "text", "id": "gofix01-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" }
]
}
]
}
]
},
{
"type": "frame",
"id": "gofix02",
"x": 660,
"y": 0,
"name": "GitHub OAuth Fix — Device code waiting state",
"clip": true,
"width": 560,
"height": 420,
"fill": "$--background",
"cornerRadius": 12,
"layout": "vertical",
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "gofix02-hdr",
"name": "Header",
"width": "fill_container",
"height": 56,
"fill": "$--background",
"padding": [0, 24],
"alignItems": "center",
"justifyContent": "space-between",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "text", "id": "gofix02-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
{ "type": "frame", "id": "gofix02-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix02-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
]
},
{
"type": "frame",
"id": "gofix02-desc",
"name": "Description",
"width": "fill_container",
"padding": [16, 24],
"children": [
{ "type": "text", "id": "gofix02-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
]
},
{
"type": "frame",
"id": "gofix02-body",
"name": "DeviceCodeCard",
"width": "fill_container",
"layout": "vertical",
"gap": 12,
"padding": [24, 24],
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "gofix02-card",
"name": "CodeCard",
"width": "fill_container",
"layout": "vertical",
"gap": 8,
"padding": [16, 24],
"cornerRadius": 8,
"alignItems": "center",
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{ "type": "text", "id": "gofix02-hint", "content": "Enter this code on GitHub:", "fontSize": 12, "fill": "$--muted-foreground" },
{
"type": "frame",
"id": "gofix02-code-row",
"gap": 8,
"alignItems": "center",
"children": [
{ "type": "text", "id": "gofix02-code", "content": "ABCD-1234", "fontSize": 24, "fontWeight": 700, "letterSpacing": 4, "fontFamily": "monospace", "fill": "$--foreground" },
{ "type": "text", "id": "gofix02-copy", "content": "[copy]", "fontSize": 12, "fill": "$--muted-foreground" }
]
},
{ "type": "text", "id": "gofix02-url", "content": "https://github.com/login/device", "fontSize": 12, "fill": "$--muted-foreground", "textDecoration": "underline" },
{
"type": "frame",
"id": "gofix02-spinner-row",
"gap": 6,
"alignItems": "center",
"children": [
{ "type": "text", "id": "gofix02-waiting", "content": "Waiting for authorization...", "fontSize": 12, "fill": "$--muted-foreground" }
]
}
]
},
{
"type": "frame",
"id": "gofix02-cancel-btn",
"name": "CancelButton",
"width": 80,
"height": 32,
"cornerRadius": 6,
"alignItems": "center",
"justifyContent": "center",
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{ "type": "text", "id": "gofix02-cancel-label", "content": "Cancel", "fontSize": 12, "fill": "$--muted-foreground" }
]
}
]
}
]
},
{
"type": "frame",
"id": "gofix03",
"x": 1320,
"y": 0,
"name": "GitHub OAuth Fix — Expired/Error state with retry",
"clip": true,
"width": 560,
"height": 420,
"fill": "$--background",
"cornerRadius": 12,
"layout": "vertical",
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"children": [
{
"type": "frame",
"id": "gofix03-hdr",
"name": "Header",
"width": "fill_container",
"height": 56,
"fill": "$--background",
"padding": [0, 24],
"alignItems": "center",
"justifyContent": "space-between",
"stroke": { "align": "inside", "thickness": { "bottom": 1 }, "fill": "$--border" },
"children": [
{ "type": "text", "id": "gofix03-title", "content": "Connect GitHub Repo", "fontSize": 16, "fontWeight": 600, "fill": "$--foreground" },
{ "type": "frame", "id": "gofix03-close", "width": 24, "height": 24, "cornerRadius": 4, "alignItems": "center", "justifyContent": "center", "children": [{ "type": "text", "id": "gofix03-x", "content": "X", "fontSize": 14, "fill": "$--muted-foreground" }] }
]
},
{
"type": "frame",
"id": "gofix03-desc",
"name": "Description",
"width": "fill_container",
"padding": [16, 24],
"children": [
{ "type": "text", "id": "gofix03-desc-t", "content": "Connect your GitHub account to clone or create vaults backed by GitHub repos.", "fontSize": 13, "fill": "$--muted-foreground", "lineHeight": 1.5 }
]
},
{
"type": "frame",
"id": "gofix03-body",
"name": "ErrorState",
"width": "fill_container",
"layout": "vertical",
"gap": 16,
"padding": [24, 24],
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "gofix03-login-btn",
"name": "LoginButton",
"width": 200,
"height": 36,
"fill": "$--foreground",
"cornerRadius": 6,
"alignItems": "center",
"justifyContent": "center",
"gap": 8,
"children": [
{ "type": "text", "id": "gofix03-gh-icon", "content": "G", "fontSize": 14, "fontWeight": 700, "fill": "$--background" },
{ "type": "text", "id": "gofix03-login-label", "content": "Login with GitHub", "fontSize": 13, "fontWeight": 500, "fill": "$--background" }
]
},
{
"type": "frame",
"id": "gofix03-error-row",
"gap": 8,
"alignItems": "center",
"children": [
{ "type": "text", "id": "gofix03-error-msg", "content": "Authorization expired. Please try again.", "fontSize": 12, "fill": "$--destructive" },
{ "type": "text", "id": "gofix03-retry-label", "content": "Retry", "fontSize": 12, "fill": "$--destructive" }
]
}
]
}
]
}
],
"variables": {}
}

View File

@@ -3,7 +3,7 @@
{
"type": "frame",
"id": "nnc_pending_save",
"name": "New Note Creation — Pending Save State",
"name": "New Note Creation — Unsaved State (Cmd+N)",
"x": 0,
"y": 0,
"width": 800,
@@ -36,7 +36,7 @@
{
"type": "frame",
"id": "nnc_tab_active",
"name": "Tab — New Note (unsaved)",
"name": "Tab — Unsaved Note (blue dot + italic)",
"layout": "horizontal",
"width": 180,
"height": 40,
@@ -53,16 +53,17 @@
"width": 8,
"height": 8,
"cornerRadius": 4,
"fill": "#22c55e"
"fill": "#3b82f6"
},
{
"type": "text",
"id": "nnc_tab_label",
"content": "Untitled",
"content": "Untitled Note",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "400"
"fontWeight": "400",
"fontStyle": "italic"
}
]
}

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -0,0 +1,165 @@
{
"children": [
{
"type": "frame",
"id": "rel_x_default",
"name": "Relationship Pill — Default (X hidden)",
"x": 0,
"y": 0,
"width": 280,
"height": "fit_content(40)",
"fill": "$--background",
"layout": "vertical",
"gap": 8,
"padding": [
16,
16
],
"theme": {
"Mode": "Light"
},
"children": [
{
"type": "text",
"id": "rel_x_default_label",
"content": "Relationship Pill — Default State",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "500"
},
{
"type": "frame",
"id": "rel_x_pill_default",
"layout": "horizontal",
"gap": 4,
"padding": [
4,
8
],
"fill": "$--accent",
"borderRadius": 6,
"width": "fit_content",
"height": "fit_content",
"children": [
{
"type": "text",
"id": "rel_x_pill_label",
"content": "Note Title",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "rectangle",
"id": "rel_x_type_icon",
"width": 14,
"height": 14,
"fill": "$--muted-foreground",
"opacity": 0.5,
"borderRadius": 2
}
]
},
{
"type": "text",
"id": "rel_x_note_default",
"content": "X button not visible in default state",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 10
}
]
},
{
"type": "frame",
"id": "rel_x_hover",
"name": "Relationship Pill — Hover (X visible)",
"x": 320,
"y": 0,
"width": 280,
"height": "fit_content(40)",
"fill": "$--background",
"layout": "vertical",
"gap": 8,
"padding": [
16,
16
],
"theme": {
"Mode": "Light"
},
"children": [
{
"type": "text",
"id": "rel_x_hover_label",
"content": "Relationship Pill — Hover State",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "500"
},
{
"type": "frame",
"id": "rel_x_pill_hover",
"layout": "horizontal",
"gap": 6,
"padding": [
4,
8
],
"fill": "$--accent",
"borderRadius": 6,
"width": "fit_content",
"height": "fit_content",
"children": [
{
"type": "text",
"id": "rel_x_pill_label_hover",
"content": "Note Title",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "500"
},
{
"type": "frame",
"id": "rel_x_trailing",
"layout": "horizontal",
"gap": 6,
"children": [
{
"type": "text",
"id": "rel_x_icon",
"content": "×",
"fill": "$--foreground",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "400"
},
{
"type": "rectangle",
"id": "rel_x_type_icon_hover",
"width": 14,
"height": 14,
"fill": "$--muted-foreground",
"opacity": 0.5,
"borderRadius": 2
}
]
}
]
},
{
"type": "text",
"id": "rel_x_note_hover",
"content": "X appears inline as flex child, before type icon. Ring border on hover.",
"fill": "$--muted-foreground",
"fontFamily": "Inter",
"fontSize": 10
}
]
}
]
}

View File

@@ -0,0 +1,665 @@
{
"children": [
{
"type": "frame",
"id": "srs_main",
"name": "Search Results — subtitle with metadata",
"x": 0,
"y": 0,
"width": 540,
"fill": "$--card",
"cornerRadius": 12,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "$--border"
},
"shadow": [
{
"x": 0,
"y": 8,
"blur": 32,
"spread": -4,
"fill": "#00000020"
}
],
"layout": "vertical",
"clip": true,
"theme": {
"Mode": "Light"
},
"children": [
{
"type": "frame",
"id": "srs_inputRow",
"name": "searchInputRow",
"width": "fill_container",
"stroke": {
"align": "inside",
"thickness": {
"bottom": 1
},
"fill": "$--border"
},
"gap": 8,
"padding": [14, 16],
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "srs_searchIcon",
"width": 18,
"height": 18,
"iconFontName": "magnifying-glass",
"iconFontFamily": "phosphor",
"fill": "$--primary"
},
{
"type": "text",
"id": "srs_query",
"fill": "$--foreground",
"content": "time management",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "srs_resultsHeader",
"name": "resultsHeader",
"width": "fill_container",
"padding": [8, 16],
"alignItems": "center",
"stroke": {
"align": "inside",
"thickness": {
"bottom": 1
},
"fill": "$--border"
},
"children": [
{
"type": "text",
"id": "srs_count",
"fill": "$--muted-foreground",
"content": "3 results \u00b7 45ms",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "500"
}
]
},
{
"type": "frame",
"id": "srs_result1",
"name": "searchResult1 (selected)",
"width": "fill_container",
"fill": "$--accent-blue-light",
"layout": "vertical",
"gap": 4,
"padding": [10, 16],
"children": [
{
"type": "frame",
"id": "srs_r1_titleRow",
"name": "titleRow",
"width": "fill_container",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srs_r1_title",
"fill": "$--foreground",
"content": "Time Management Strategies",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "600"
},
{
"type": "frame",
"id": "srs_r1_badge",
"name": "typeBadge",
"fill": "$--accent-green-light",
"cornerRadius": 4,
"padding": [2, 6],
"children": [
{
"type": "text",
"id": "srs_r1_badgeTxt",
"fill": "$--accent-green",
"content": "Evergreen",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600"
}
]
}
]
},
{
"type": "frame",
"id": "srs_r1_meta",
"name": "metadataRow",
"width": "fill_container",
"gap": 4,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srs_r1_date",
"fill": "$--muted-foreground",
"content": "2d ago",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r1_sep1",
"fill": "$--muted-foreground",
"content": "\u00b7",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r1_words",
"fill": "$--muted-foreground",
"content": "1,247 words",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r1_sep2",
"fill": "$--muted-foreground",
"content": "\u00b7",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r1_links",
"fill": "$--muted-foreground",
"content": "5 links",
"fontFamily": "Inter",
"fontSize": 11
}
]
}
]
},
{
"type": "frame",
"id": "srs_result2",
"name": "searchResult2",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [10, 16],
"children": [
{
"type": "frame",
"id": "srs_r2_titleRow",
"name": "titleRow",
"width": "fill_container",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srs_r2_title",
"fill": "$--foreground",
"content": "Weekly Review Process",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
},
{
"type": "frame",
"id": "srs_r2_badge",
"name": "typeBadge",
"fill": "$--accent-purple-light",
"cornerRadius": 4,
"padding": [2, 6],
"children": [
{
"type": "text",
"id": "srs_r2_badgeTxt",
"fill": "$--accent-purple",
"content": "Procedure",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600"
}
]
}
]
},
{
"type": "frame",
"id": "srs_r2_meta",
"name": "metadataRow",
"width": "fill_container",
"gap": 4,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srs_r2_date",
"fill": "$--muted-foreground",
"content": "5h ago",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r2_sep1",
"fill": "$--muted-foreground",
"content": "\u00b7",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r2_created",
"fill": "$--muted-foreground",
"content": "Created Jan 15",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r2_sep2",
"fill": "$--muted-foreground",
"content": "\u00b7",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r2_words",
"fill": "$--muted-foreground",
"content": "856 words",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r2_sep3",
"fill": "$--muted-foreground",
"content": "\u00b7",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r2_links",
"fill": "$--muted-foreground",
"content": "12 links",
"fontFamily": "Inter",
"fontSize": 11
}
]
}
]
},
{
"type": "frame",
"id": "srs_result3",
"name": "searchResult3",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [10, 16, 14, 16],
"children": [
{
"type": "frame",
"id": "srs_r3_titleRow",
"name": "titleRow",
"width": "fill_container",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srs_r3_title",
"fill": "$--foreground",
"content": "Q1 Planning",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
}
]
},
{
"type": "frame",
"id": "srs_r3_meta",
"name": "metadataRow",
"width": "fill_container",
"gap": 4,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srs_r3_date",
"fill": "$--muted-foreground",
"content": "Jan 3",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r3_sep1",
"fill": "$--muted-foreground",
"content": "\u00b7",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_r3_words",
"fill": "$--muted-foreground",
"content": "2,103 words",
"fontFamily": "Inter",
"fontSize": 11
}
]
}
]
},
{
"type": "frame",
"id": "srs_footer",
"name": "searchFooter",
"width": "fill_container",
"fill": "$--muted",
"stroke": {
"align": "inside",
"thickness": {
"top": 1
},
"fill": "$--border"
},
"gap": 16,
"padding": [8, 16],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "srs_hint1",
"fill": "$--muted-foreground",
"content": "\u2191\u2193 Navigate",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_hint2",
"fill": "$--muted-foreground",
"content": "\u21b5 Open",
"fontFamily": "Inter",
"fontSize": 11
},
{
"type": "text",
"id": "srs_hint3",
"fill": "$--muted-foreground",
"content": "Esc Close",
"fontFamily": "Inter",
"fontSize": 11
}
]
}
]
},
{
"type": "frame",
"id": "srs_states",
"name": "Search Results — subtitle states",
"x": 600,
"y": 0,
"width": 540,
"layout": "vertical",
"gap": 24,
"padding": [24, 24],
"fill": "$--background",
"cornerRadius": 12,
"stroke": {
"align": "inside",
"thickness": 1,
"fill": "$--border"
},
"theme": {
"Mode": "Light"
},
"children": [
{
"type": "text",
"id": "srs_states_title",
"fill": "$--foreground",
"content": "Metadata Subtitle — States",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
},
{
"type": "frame",
"id": "srs_state1",
"name": "State: all metadata present",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [10, 16],
"stroke": {
"align": "inside",
"thickness": {
"bottom": 1
},
"fill": "$--border"
},
"children": [
{
"type": "text",
"id": "srs_s1_label",
"fill": "$--accent-blue",
"content": "All metadata present (modified \u2260 created)",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600"
},
{
"type": "text",
"id": "srs_s1_title",
"fill": "$--foreground",
"content": "Time Management Strategies",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
},
{
"type": "text",
"id": "srs_s1_meta",
"fill": "$--muted-foreground",
"content": "2d ago \u00b7 Created Jan 15 \u00b7 1,247 words \u00b7 5 links",
"fontFamily": "Inter",
"fontSize": 11
}
]
},
{
"type": "frame",
"id": "srs_state2",
"name": "State: no created date (same as modified)",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [10, 16],
"stroke": {
"align": "inside",
"thickness": {
"bottom": 1
},
"fill": "$--border"
},
"children": [
{
"type": "text",
"id": "srs_s2_label",
"fill": "$--accent-blue",
"content": "Modified = Created (skip 'Created' field)",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600"
},
{
"type": "text",
"id": "srs_s2_title",
"fill": "$--foreground",
"content": "Quick Note",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
},
{
"type": "text",
"id": "srs_s2_meta",
"fill": "$--muted-foreground",
"content": "just now \u00b7 342 words \u00b7 2 links",
"fontFamily": "Inter",
"fontSize": 11
}
]
},
{
"type": "frame",
"id": "srs_state3",
"name": "State: no links (zero outgoingLinks)",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [10, 16],
"stroke": {
"align": "inside",
"thickness": {
"bottom": 1
},
"fill": "$--border"
},
"children": [
{
"type": "text",
"id": "srs_s3_label",
"fill": "$--accent-blue",
"content": "No links (omitted from metadata)",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600"
},
{
"type": "text",
"id": "srs_s3_title",
"fill": "$--foreground",
"content": "Random Thought",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
},
{
"type": "text",
"id": "srs_s3_meta",
"fill": "$--muted-foreground",
"content": "3h ago \u00b7 128 words",
"fontFamily": "Inter",
"fontSize": 11
}
]
},
{
"type": "frame",
"id": "srs_state4",
"name": "State: empty note (zero words)",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [10, 16],
"stroke": {
"align": "inside",
"thickness": {
"bottom": 1
},
"fill": "$--border"
},
"children": [
{
"type": "text",
"id": "srs_s4_label",
"fill": "$--accent-blue",
"content": "Empty note (word count shows 'Empty')",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600"
},
{
"type": "text",
"id": "srs_s4_title",
"fill": "$--foreground",
"content": "Untitled Draft",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
},
{
"type": "text",
"id": "srs_s4_meta",
"fill": "$--muted-foreground",
"content": "5d ago \u00b7 Empty",
"fontFamily": "Inter",
"fontSize": 11
}
]
},
{
"type": "frame",
"id": "srs_state5",
"name": "State: no date at all",
"width": "fill_container",
"layout": "vertical",
"gap": 4,
"padding": [10, 16],
"children": [
{
"type": "text",
"id": "srs_s5_label",
"fill": "$--accent-blue",
"content": "No date available (graceful degradation)",
"fontFamily": "Inter",
"fontSize": 10,
"fontWeight": "600"
},
{
"type": "text",
"id": "srs_s5_title",
"fill": "$--foreground",
"content": "Legacy Import",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500"
},
{
"type": "text",
"id": "srs_s5_meta",
"fill": "$--muted-foreground",
"content": "89 words \u00b7 1 link",
"fontFamily": "Inter",
"fontSize": 11
}
]
}
]
}
],
"variables": {}
}

File diff suppressed because one or more lines are too long

202
design/zoom-shortcuts.pen Normal file
View File

@@ -0,0 +1,202 @@
{
"children": [
{
"type": "frame",
"id": "zs_normal",
"name": "Zoom Shortcuts — StatusBar (100% / default)",
"x": 0,
"y": 0,
"theme": { "Mode": "Light" },
"width": 800,
"height": 32,
"fill": "$--background",
"layout": "horizontal",
"gap": 0,
"padding": [0, 12],
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "zs_normal_desc",
"name": "description",
"width": "fill_container",
"children": [
{
"type": "text",
"id": "zs_normal_desc_text",
"fill": "$--muted-foreground",
"content": "StatusBar at 100% zoom — zoom indicator is HIDDEN. Cmd+= zooms in, Cmd+- zooms out, Cmd+0 resets to 100%.",
"fontFamily": "Inter",
"fontSize": 11
}
]
}
]
},
{
"type": "frame",
"id": "zs_zoomed",
"name": "Zoom Shortcuts — StatusBar (150% / zoomed in)",
"x": 0,
"y": 60,
"theme": { "Mode": "Light" },
"width": 800,
"height": 32,
"fill": "$--background",
"layout": "horizontal",
"gap": 0,
"padding": [0, 12],
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "zs_statusbar_zoomed",
"name": "StatusBar — 150% (zoom indicator visible)",
"layout": "horizontal",
"gap": 8,
"alignItems": "center",
"padding": [0, 8],
"children": [
{
"type": "text",
"id": "zs_wordcount_z",
"fill": "$--muted-foreground",
"content": "1,247 words",
"fontFamily": "IBM Plex Mono",
"fontSize": 11
},
{
"type": "frame",
"id": "zs_zoom_badge",
"name": "Zoom % Badge",
"fill": "$--muted",
"cornerRadius": 4,
"padding": [2, 6],
"alignItems": "center",
"gap": 4,
"children": [
{
"type": "text",
"id": "zs_zoom_label",
"fill": "$--foreground",
"content": "150%",
"fontFamily": "IBM Plex Mono",
"fontSize": 11,
"fontWeight": "500"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "zs_cmd_palette",
"name": "Zoom Shortcuts — Command Palette entries",
"x": 0,
"y": 120,
"theme": { "Mode": "Light" },
"width": 540,
"height": 120,
"fill": "$--popover",
"cornerRadius": 12,
"layout": "vertical",
"gap": 0,
"children": [
{
"type": "frame",
"id": "zs_cmd_row1",
"name": "Command — Zoom In (⌘=)",
"layout": "horizontal",
"width": "fill_container",
"padding": [8, 12],
"gap": 8,
"alignItems": "center",
"fill": "$--accent",
"children": [
{
"type": "text",
"id": "zs_cmd_row1_label",
"fill": "$--accent-foreground",
"content": "Zoom In",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500",
"width": "fill_container"
},
{
"type": "text",
"id": "zs_cmd_row1_shortcut",
"fill": "$--muted-foreground",
"content": "Command+=",
"fontFamily": "IBM Plex Mono",
"fontSize": 11
}
]
},
{
"type": "frame",
"id": "zs_cmd_row2",
"name": "Command — Zoom Out (⌘-)",
"layout": "horizontal",
"width": "fill_container",
"padding": [8, 12],
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "zs_cmd_row2_label",
"fill": "$--foreground",
"content": "Zoom Out",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500",
"width": "fill_container"
},
{
"type": "text",
"id": "zs_cmd_row2_shortcut",
"fill": "$--muted-foreground",
"content": "Command+-",
"fontFamily": "IBM Plex Mono",
"fontSize": 11
}
]
},
{
"type": "frame",
"id": "zs_cmd_row3",
"name": "Command — Reset Zoom (⌘0)",
"layout": "horizontal",
"width": "fill_container",
"padding": [8, 12],
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "text",
"id": "zs_cmd_row3_label",
"fill": "$--foreground",
"content": "Reset Zoom",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "500",
"width": "fill_container"
},
{
"type": "text",
"id": "zs_cmd_row3_shortcut",
"fill": "$--muted-foreground",
"content": "Command+0",
"fontFamily": "IBM Plex Mono",
"fontSize": 11
}
]
}
]
}
],
"variables": {}
}

View File

@@ -131,29 +131,97 @@ The context picker controls which notes are sent to the AI as context:
### MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools:
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
| Tool | Description |
|------|-------------|
| `open_note` | Open and read a note by path |
| `read_note` | Read note content (alias) |
| `create_note` | Create new note with frontmatter |
| `search_notes` | Search by title or content |
| `append_to_note` | Append text to a note |
#### Tool Surface (14 tools)
**Transports:**
- **stdio** — standard MCP transport (`node mcp-server/index.js`)
- **WebSocket** — live bridge for app integration (`node mcp-server/ws-bridge.js`, port 9710)
| Tool | Params | Description |
|------|--------|-------------|
| `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 |
| `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 |
| `delete_note` | `path` | Delete a note file from the vault |
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
| `vault_context` | — | Get vault summary: entity types + 20 recent notes |
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
| `ui_open_tab` | `path` | Open a note in a new UI tab |
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
#### Transports
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
- **WebSocket** — live bridge for Laputa app integration:
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
#### Auto-Registration
On app startup, Laputa automatically registers itself as an MCP server in:
- `~/.claude/mcp.json` (Claude Code)
- `~/.cursor/mcp.json` (Cursor)
The registration is non-destructive (additive — preserves other MCP servers) and uses `upsert` semantics. The entry points to `mcp-server/index.js` with the active vault path as `VAULT_PATH` env var.
Registration also runs from the frontend via the `useMcpRegistration` hook and `register_mcp_tools` Tauri command, ensuring the config stays up-to-date when the vault path changes.
#### Architecture
```
┌─────────────────────────────────────────────────────┐
│ MCP Server (Node.js) │
│ │
│ index.js ─── stdio transport ──→ Claude Code │
│ │ Cursor │
│ ├── vault.js (9 vault operations) │
│ │ ├── findMarkdownFiles ├── deleteNote │
│ │ ├── readNote ├── linkNotes │
│ │ ├── createNote ├── listNotes │
│ │ ├── searchNotes ├── vaultContext │
│ │ ├── appendToNote │
│ │ └── editNoteFrontmatter │
│ │ │
│ └── ws-bridge.js │
│ ├── port 9710: tool bridge ←→ AI clients │
│ └── port 9711: UI bridge ←→ Frontend │
│ │
│ Spawned by Tauri (mcp.rs) on app startup │
│ Auto-registered in ~/.claude/mcp.json │
└─────────────────────────────────────────────────────┘
```
### WebSocket Bridge
The WebSocket bridge (`useMcpBridge` hook) enables real-time vault operations from the frontend:
The WebSocket bridge enables real-time vault operations from both the frontend and external AI clients:
```
Frontend (useMcpBridge) ←→ ws://localhost:9710 ←→ ws-bridge.js ←→ vault.js
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions
```
Protocol: JSON-RPC-like with `{id, tool, args}` requests and `{id, result}` responses.
**Tool bridge protocol** (port 9710):
- Request: `{ "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }`
- Response: `{ "id": "req-1", "result": { ... } }`
- Error: `{ "id": "req-1", "error": "message" }`
**UI bridge protocol** (port 9711):
- Broadcast: `{ "type": "ui_action", "action": "open_note", "path": "..." }`
### Rust MCP Module
`src-tauri/src/mcp.rs` manages the MCP server lifecycle:
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `register_mcp(vault_path)` | Writes Laputa entry to Claude Code and Cursor MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
### Rust Backend (Tauri)
@@ -170,24 +238,31 @@ The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streamin
| `src/components/AIChatPanel.tsx` | Main UI: context bar, messages, input, quick actions |
| `src/hooks/useAIChat.ts` | Chat state: messages, streaming, send/retry/clear |
| `src/hooks/useMcpBridge.ts` | WebSocket client for MCP vault tool calls |
| `src/hooks/useMcpRegistration.ts` | Auto-registers Laputa MCP on vault load |
| `src/utils/ai-chat.ts` | API client, token estimation, context builder |
| `src-tauri/src/ai_chat.rs` | Rust Anthropic API client (non-streaming) |
| `mcp-server/index.js` | MCP server entry (stdio transport) |
| `mcp-server/vault.js` | Vault file operations |
| `mcp-server/ws-bridge.js` | WebSocket bridge server |
| `src-tauri/src/mcp.rs` | MCP server spawning + config registration |
| `mcp-server/index.js` | MCP server entry (stdio transport, 14 tools) |
| `mcp-server/vault.js` | Vault file operations (9 functions) |
| `mcp-server/ws-bridge.js` | WebSocket bridge server (tool + UI bridges) |
| `mcp-server/test.js` | 26 unit tests for all vault.js functions |
## Data Flow
### Startup Sequence
```
1. App mounts
2. useVaultLoader fires:
1. Tauri setup:
a. run_startup_tasks() → purge trash, migrate frontmatter, register MCP config
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
2. App mounts
3. useVaultLoader fires:
a. isTauri() ? invoke('list_vault') : mockInvoke('list_vault')
→ VaultEntry[] stored in state
b. Load all content (mock mode) or on-demand (Tauri mode)
c. invoke('get_modified_files') → ModifiedFile[] stored in state
3. User clicks note in NoteList
d. useMcpRegistration → invoke('register_mcp_tools') → ensures MCP config current
4. User clicks note in NoteList
4. useNoteActions.handleSelectNote:
a. invoke('get_note_content') → raw markdown string
b. Add tab { entry, content } to tabs state
@@ -255,6 +330,7 @@ All commands are defined in `src-tauri/src/lib.rs` and registered via `tauri::ge
| `git_commit` | `vault_path, message` | `String` | `git::git_commit()` |
| `git_push` | `vault_path` | `String` | `git::git_push()` |
| `ai_chat` | `request: AiChatRequest` | `AiChatResponse` | `ai_chat::send_chat()` |
| `register_mcp_tools` | `vault_path` | `String` ("registered" or "updated") | `mcp::register_mcp()` |
All commands return `Result<T, String>`. Errors are serialized as JSON error objects to the frontend.

View File

@@ -6,11 +6,16 @@
* VAULT_PATH=/path/to/vault node index.js
*
* Tools:
* - open_note: Open and read a note by path
* - read_note: Read note content (alias for consistency)
* - open_note / read_note: Read a note by path
* - create_note: Create a new note with title and optional frontmatter
* - search_notes: Search notes by title or content
* - append_to_note: Append text to an existing note
* - edit_note_frontmatter: Merge a patch into a note's YAML frontmatter
* - delete_note: Delete a note file
* - link_notes: Add a title to an array property in a note's frontmatter
* - list_notes: List all notes, optionally filtered by type
* - vault_context: Get vault types and recent notes
* - ui_open_note / ui_open_tab / ui_highlight / ui_set_filter: UI actions
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
@@ -18,9 +23,24 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { readNote, createNote, searchNotes, appendToNote } from './vault.js'
import {
readNote, createNote, searchNotes, appendToNote,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
} from './vault.js'
import { startUiBridge } from './ws-bridge.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions
const uiBridge = startUiBridge(WS_UI_PORT)
function broadcastUiAction(action, payload) {
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
for (const client of uiBridge.clients) {
if (client.readyState === 1) client.send(msg)
}
}
const TOOLS = [
{
@@ -82,6 +102,103 @@ const TOOLS = [
required: ['path', 'text'],
},
},
{
name: 'edit_note_frontmatter',
description: 'Merge a patch object into a note\'s YAML frontmatter',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
patch: { type: 'object', description: 'Key-value pairs to merge into frontmatter' },
},
required: ['path', 'patch'],
},
},
{
name: 'delete_note',
description: 'Delete a note file from the vault',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note to delete' },
},
required: ['path'],
},
},
{
name: 'link_notes',
description: 'Add a target title to an array property in a note\'s frontmatter (e.g. add "Marco" to people: [])',
inputSchema: {
type: 'object',
properties: {
source_path: { type: 'string', description: 'Relative path to the source note' },
property: { type: 'string', description: 'Frontmatter property name (e.g. "people", "tags")' },
target_title: { type: 'string', description: 'Title to add to the array' },
},
required: ['source_path', 'property', 'target_title'],
},
},
{
name: 'list_notes',
description: 'List all notes in the vault, optionally filtered by type frontmatter field',
inputSchema: {
type: 'object',
properties: {
type_filter: { type: 'string', description: 'Filter by type frontmatter value' },
sort: { type: 'string', enum: ['title', 'mtime'], description: 'Sort order (default: title)' },
},
},
},
{
name: 'vault_context',
description: 'Get vault context: unique entity types and 20 most recently modified notes',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'ui_open_note',
description: 'Open a note in the Laputa UI editor',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
},
required: ['path'],
},
},
{
name: 'ui_open_tab',
description: 'Open a note in a new tab in the Laputa UI',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
},
required: ['path'],
},
},
{
name: 'ui_highlight',
description: 'Highlight a UI element in the Laputa interface',
inputSchema: {
type: 'object',
properties: {
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'UI element to highlight' },
path: { type: 'string', description: 'Relative path to the note (optional)' },
},
required: ['element'],
},
},
{
name: 'ui_set_filter',
description: 'Set the sidebar filter to show notes of a specific type',
inputSchema: {
type: 'object',
properties: {
type: { type: 'string', description: 'Type to filter by' },
},
required: ['type'],
},
},
]
const TOOL_HANDLERS = {
@@ -90,6 +207,15 @@ const TOOL_HANDLERS = {
create_note: handleCreateNote,
search_notes: handleSearchNotes,
append_to_note: handleAppendToNote,
edit_note_frontmatter: handleEditFrontmatter,
delete_note: handleDeleteNote,
link_notes: handleLinkNotes,
list_notes: handleListNotes,
vault_context: handleVaultContext,
ui_open_note: handleUiOpenNote,
ui_open_tab: handleUiOpenTab,
ui_highlight: handleUiHighlight,
ui_set_filter: handleUiSetFilter,
}
async function handleReadNote(args) {
@@ -117,6 +243,54 @@ async function handleAppendToNote(args) {
return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] }
}
async function handleEditFrontmatter(args) {
const updated = await editNoteFrontmatter(VAULT_PATH, args.path, args.patch)
return { content: [{ type: 'text', text: JSON.stringify(updated) }] }
}
async function handleDeleteNote(args) {
await deleteNote(VAULT_PATH, args.path)
return { content: [{ type: 'text', text: `Deleted ${args.path}` }] }
}
async function handleLinkNotes(args) {
const arr = await linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title)
return { content: [{ type: 'text', text: `${args.property}: [${arr.join(', ')}]` }] }
}
async function handleListNotes(args) {
const notes = await listNotes(VAULT_PATH, args.type_filter, args.sort)
const text = notes.length === 0
? 'No notes found.'
: notes.map(n => `${n.title} (${n.path})${n.type ? ` [${n.type}]` : ''}`).join('\n')
return { content: [{ type: 'text', text }] }
}
async function handleVaultContext() {
const ctx = await vaultContext(VAULT_PATH)
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
}
function handleUiOpenNote(args) {
broadcastUiAction('open_note', { path: args.path })
return { content: [{ type: 'text', text: `Opening ${args.path} in UI` }] }
}
function handleUiOpenTab(args) {
broadcastUiAction('open_tab', { path: args.path })
return { content: [{ type: 'text', text: `Opening tab for ${args.path}` }] }
}
function handleUiHighlight(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
}
function handleUiSetFilter(args) {
broadcastUiAction('set_filter', { type: args.type })
return { content: [{ type: 'text', text: `Filter set to ${args.type}` }] }
}
// --- Server setup ---
const server = new Server(

View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"ws": "^8.19.0"
}
},
@@ -110,6 +111,15 @@
}
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -334,6 +344,19 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -425,6 +448,18 @@
"express": ">= 4.11"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -544,6 +579,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -637,6 +687,15 @@
"node": ">= 0.10"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -658,6 +717,19 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -670,6 +742,15 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -902,6 +983,19 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -1046,6 +1140,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1055,6 +1155,15 @@
"node": ">= 0.8"
}
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"ws": "^8.19.0"
}
}

View File

@@ -3,14 +3,16 @@ import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import { readNote, createNote, searchNotes, appendToNote, findMarkdownFiles } from './vault.js'
import {
readNote, createNote, searchNotes, appendToNote, findMarkdownFiles,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
} from './vault.js'
let tmpDir
before(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
// Create test vault structure
await fs.mkdir(path.join(tmpDir, 'project'), { recursive: true })
await fs.mkdir(path.join(tmpDir, 'note'), { recursive: true })
@@ -33,6 +35,19 @@ is_a: Note
# Daily Log
Today I worked on the MCP server implementation.
`)
await fs.writeFile(path.join(tmpDir, 'project', 'second-project.md'), `---
title: Second Project
type: Project
status: Draft
belongs_to:
- "[[project/test-project]]"
---
# Second Project
Another project for testing list and context.
`)
})
@@ -43,9 +58,10 @@ after(async () => {
describe('findMarkdownFiles', () => {
it('should find all .md files recursively', async () => {
const files = await findMarkdownFiles(tmpDir)
assert.equal(files.length, 2)
assert.equal(files.length, 3)
assert.ok(files.some(f => f.endsWith('test-project.md')))
assert.ok(files.some(f => f.endsWith('daily-log.md')))
assert.ok(files.some(f => f.endsWith('second-project.md')))
})
})
@@ -100,7 +116,7 @@ describe('searchNotes', () => {
})
it('should respect limit', async () => {
const results = await searchNotes(tmpDir, 'note', 1)
const results = await searchNotes(tmpDir, 'project', 1)
assert.ok(results.length <= 1)
})
})
@@ -113,3 +129,139 @@ describe('appendToNote', () => {
assert.ok(content.includes('Finished testing.'))
})
})
describe('editNoteFrontmatter', () => {
it('should merge a patch into frontmatter', async () => {
const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' })
assert.equal(updated.status, 'Completed')
assert.equal(updated.priority, 'High')
assert.equal(updated.title, 'Test Project')
})
it('should preserve existing frontmatter fields', async () => {
const content = await readNote(tmpDir, 'project/test-project.md')
assert.ok(content.includes('is_a: Project'))
assert.ok(content.includes('status: Completed'))
})
it('should throw for missing file', async () => {
await assert.rejects(
() => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }),
{ code: 'ENOENT' }
)
})
})
describe('deleteNote', () => {
it('should delete an existing note', async () => {
const delPath = 'note/to-delete.md'
await createNote(tmpDir, delPath, 'To Delete')
const absPath = path.join(tmpDir, delPath)
// Verify it exists
await fs.access(absPath)
await deleteNote(tmpDir, delPath)
await assert.rejects(
() => fs.access(absPath),
{ code: 'ENOENT' }
)
})
it('should throw for missing file', async () => {
await assert.rejects(
() => deleteNote(tmpDir, 'nonexistent.md'),
{ code: 'ENOENT' }
)
})
})
describe('linkNotes', () => {
it('should add a target to an array property', async () => {
const linkPath = 'project/link-test.md'
await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' })
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
assert.deepEqual(result, ['[[note/daily-log]]'])
})
it('should not duplicate existing links', async () => {
const linkPath = 'project/link-test.md'
await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
assert.equal(result.length, 1)
})
it('should add multiple distinct links', async () => {
const linkPath = 'project/link-test.md'
await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
// Should have daily-log and test-project
assert.ok(result.includes('[[note/daily-log]]'))
assert.ok(result.includes('[[project/test-project]]'))
assert.equal(result.length, 2)
})
})
describe('listNotes', () => {
it('should list all notes sorted by title', async () => {
const notes = await listNotes(tmpDir)
assert.ok(notes.length >= 3)
// Verify sorted by title
for (let i = 1; i < notes.length; i++) {
assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0)
}
})
it('should filter by type', async () => {
const projects = await listNotes(tmpDir, 'Project')
assert.ok(projects.length >= 1)
for (const n of projects) {
assert.equal(n.type, 'Project')
}
})
it('should return empty for unknown type', async () => {
const notes = await listNotes(tmpDir, 'UnknownType12345')
assert.equal(notes.length, 0)
})
it('should support mtime sorting', async () => {
const notes = await listNotes(tmpDir, undefined, 'mtime')
assert.ok(notes.length >= 1)
// Just verify it returns results without crashing
assert.ok(notes[0].path)
assert.ok(notes[0].title)
})
})
describe('vaultContext', () => {
it('should return types, recent notes, and vault path', async () => {
const ctx = await vaultContext(tmpDir)
assert.ok(Array.isArray(ctx.types))
assert.ok(Array.isArray(ctx.recentNotes))
assert.equal(ctx.vaultPath, tmpDir)
})
it('should include known entity types', async () => {
const ctx = await vaultContext(tmpDir)
assert.ok(ctx.types.includes('Project'))
assert.ok(ctx.types.includes('Note'))
})
it('should cap recent notes at 20', async () => {
const ctx = await vaultContext(tmpDir)
assert.ok(ctx.recentNotes.length <= 20)
})
it('should include path and title in recent notes', async () => {
const ctx = await vaultContext(tmpDir)
for (const note of ctx.recentNotes) {
assert.ok(note.path)
assert.ok(note.title)
}
})
})

View File

@@ -3,6 +3,7 @@
*/
import fs from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
/**
* Recursively find all .md files under a directory.
@@ -104,6 +105,119 @@ export async function appendToNote(vaultPath, notePath, text) {
await fs.writeFile(absPath, current + separator + text + '\n', 'utf-8')
}
/**
* Merge a patch object into a note's YAML frontmatter.
* @param {string} vaultPath
* @param {string} notePath
* @param {Record<string, unknown>} patch
* @returns {Promise<Record<string, unknown>>} The updated frontmatter.
*/
export async function editNoteFrontmatter(vaultPath, notePath, patch) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
const raw = await fs.readFile(absPath, 'utf-8')
const parsed = matter(raw)
Object.assign(parsed.data, patch)
const updated = matter.stringify(parsed.content, parsed.data)
await fs.writeFile(absPath, updated, 'utf-8')
return parsed.data
}
/**
* Delete a note file.
* @param {string} vaultPath
* @param {string} notePath
* @returns {Promise<void>}
*/
export async function deleteNote(vaultPath, notePath) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
await fs.unlink(absPath)
}
/**
* Add a target title to an array property in a note's frontmatter.
* Creates the property as an array if it doesn't exist.
* @param {string} vaultPath
* @param {string} sourcePath
* @param {string} property
* @param {string} targetTitle
* @returns {Promise<string[]>} The updated array.
*/
export async function linkNotes(vaultPath, sourcePath, property, targetTitle) {
const absPath = path.isAbsolute(sourcePath) ? sourcePath : path.join(vaultPath, sourcePath)
const raw = await fs.readFile(absPath, 'utf-8')
const parsed = matter(raw)
const current = Array.isArray(parsed.data[property]) ? parsed.data[property] : []
if (!current.includes(targetTitle)) {
current.push(targetTitle)
}
parsed.data[property] = current
const updated = matter.stringify(parsed.content, parsed.data)
await fs.writeFile(absPath, updated, 'utf-8')
return current
}
/**
* List all notes in the vault, optionally filtered by type.
* @param {string} vaultPath
* @param {string} [typeFilter]
* @param {string} [sort] - 'title' or 'mtime' (default: 'title')
* @returns {Promise<Array<{path: string, title: string, type: string|null}>>}
*/
export async function listNotes(vaultPath, typeFilter, sort = 'title') {
const files = await findMarkdownFiles(vaultPath)
const notes = await Promise.all(files.map(async (filePath) => {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const relativePath = path.relative(vaultPath, filePath)
const title = parsed.data.title || extractTitle(raw, path.basename(filePath, '.md'))
const type = parsed.data.type || parsed.data.is_a || null
const stat = sort === 'mtime' ? await fs.stat(filePath) : null
return { path: relativePath, title, type, mtime: stat?.mtimeMs ?? 0 }
}))
const filtered = typeFilter
? notes.filter(n => n.type === typeFilter)
: notes
if (sort === 'mtime') {
filtered.sort((a, b) => b.mtime - a.mtime)
} else {
filtered.sort((a, b) => a.title.localeCompare(b.title))
}
return filtered.map(({ mtime: _mtime, ...rest }) => rest)
}
/**
* Get vault context: unique types and 20 most recent notes.
* @param {string} vaultPath
* @returns {Promise<{types: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>}
*/
export async function vaultContext(vaultPath) {
const files = await findMarkdownFiles(vaultPath)
const typesSet = new Set()
const notesWithMtime = []
for (const filePath of files) {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const type = parsed.data.type || parsed.data.is_a || null
if (type) typesSet.add(type)
const stat = await fs.stat(filePath)
notesWithMtime.push({
path: path.relative(vaultPath, filePath),
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
})
}
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
return { types: [...typesSet].sort(), recentNotes, vaultPath }
}
// --- Helpers ---
/**

View File

@@ -5,19 +5,46 @@
* Exposes vault operations over WebSocket so the Laputa app frontend
* can invoke MCP tools in real-time without going through stdio.
*
* Usage:
* VAULT_PATH=/path/to/vault WS_PORT=9710 node ws-bridge.js
* Port 9710: Tool bridge — Claude/AI clients call vault tools here.
* Port 9711: UI bridge — Frontend listens for UI action broadcasts.
*
* Protocol:
* Usage:
* VAULT_PATH=/path/to/vault WS_PORT=9710 WS_UI_PORT=9711 node ws-bridge.js
*
* Protocol (tool bridge):
* Client sends: { "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }
* Server sends: { "id": "req-1", "result": { ... } }
* On error: { "id": "req-1", "error": "message" }
*
* Protocol (UI bridge):
* Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." }
*/
import { WebSocketServer } from 'ws'
import { readNote, createNote, searchNotes, appendToNote } from './vault.js'
import {
readNote, createNote, searchNotes, appendToNote,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
} from './vault.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
/** @type {WebSocketServer | null} */
let uiBridge = null
function broadcastUiAction(action, payload) {
if (!uiBridge) return
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
for (const client of uiBridge.clients) {
if (client.readyState === 1) client.send(msg)
}
}
function buildFrontmatter(args) {
const fm = {}
if (args.is_a) fm.is_a = args.is_a
return fm
}
const TOOL_HANDLERS = {
open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
@@ -25,12 +52,15 @@ const TOOL_HANDLERS = {
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)),
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })),
}
function buildFrontmatter(args) {
const fm = {}
if (args.is_a) fm.is_a = args.is_a
return fm
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch),
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => ({ ok: true })),
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title),
list_notes: (args) => listNotes(VAULT_PATH, args.type_filter, args.sort),
vault_context: () => vaultContext(VAULT_PATH),
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { type: args.type }); return { ok: true } },
}
async function handleMessage(data) {
@@ -50,6 +80,17 @@ async function handleMessage(data) {
}
}
export function startUiBridge(port = WS_UI_PORT) {
uiBridge = new WebSocketServer({ port })
uiBridge.on('connection', () => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
})
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
return uiBridge
}
export function startBridge(port = WS_PORT) {
const wss = new WebSocketServer({ port })
@@ -75,5 +116,6 @@ export function startBridge(port = WS_PORT) {
// Run directly if invoked as main module
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) {
startUiBridge()
startBridge()
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 39 KiB

BIN
src-tauri/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
src-tauri/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 161 KiB

View File

@@ -2,12 +2,16 @@ pub mod ai_chat;
pub mod frontmatter;
pub mod git;
pub mod github;
pub mod mcp;
pub mod menu;
pub mod search;
pub mod settings;
pub mod vault;
use std::borrow::Cow;
use std::path::Path;
use std::process::Child;
use std::sync::Mutex;
use ai_chat::{AiChatRequest, AiChatResponse};
use frontmatter::FrontmatterValue;
@@ -17,18 +21,37 @@ use search::SearchResponse;
use settings::Settings;
use vault::{RenameResult, VaultEntry};
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
/// home directory cannot be determined.
fn expand_tilde(path: &str) -> Cow<'_, str> {
if path == "~" {
if let Some(home) = dirs::home_dir() {
return Cow::Owned(home.to_string_lossy().into_owned());
}
} else if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
}
}
Cow::Borrowed(path)
}
#[tauri::command]
fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
vault::scan_vault_cached(Path::new(&path))
let path = expand_tilde(&path);
vault::scan_vault_cached(Path::new(path.as_ref()))
}
#[tauri::command]
fn get_note_content(path: String) -> Result<String, String> {
vault::get_note_content(Path::new(&path))
let path = expand_tilde(&path);
vault::get_note_content(Path::new(path.as_ref()))
}
#[tauri::command]
fn save_note_content(path: String, content: String) -> Result<(), String> {
let path = expand_tilde(&path);
vault::save_note_content(&path, &content)
}
@@ -38,26 +61,33 @@ fn update_frontmatter(
key: String,
value: FrontmatterValue,
) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::update_frontmatter(&path, &key, value)
}
#[tauri::command]
fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_history(&vault_path, &path)
}
#[tauri::command]
fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_modified_files(&vault_path)
}
#[tauri::command]
fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_diff(&vault_path, &path)
}
@@ -67,26 +97,32 @@ fn get_file_diff_at_commit(
path: String,
commit_hash: String,
) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
#[tauri::command]
fn git_commit(vault_path: String, message: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit(&vault_path, &message)
}
#[tauri::command]
fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_last_commit_info(&vault_path)
}
#[tauri::command]
fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
let vault_path = expand_tilde(&vault_path);
git::git_pull(&vault_path)
}
#[tauri::command]
fn git_push(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_push(&vault_path)
}
@@ -97,6 +133,7 @@ async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
#[tauri::command]
fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::save_image(&vault_path, &filename, &data)
}
@@ -106,24 +143,49 @@ fn rename_note(
old_path: String,
new_title: String,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note(&vault_path, &old_path, &new_title)
}
#[tauri::command]
fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
vault::purge_trash(&vault_path)
}
#[tauri::command]
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = match target_path {
Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(),
_ => vault::default_vault_path()?.to_string_lossy().to_string(),
};
vault::create_getting_started_vault(&path)
}
#[tauri::command]
fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
frontmatter::update_frontmatter(path, "Archived", FrontmatterValue::Bool(true))?;
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
@@ -134,8 +196,13 @@ fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut count = 0;
for path in &paths {
frontmatter::update_frontmatter(path, "Trashed", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(path, "Trashed at", FrontmatterValue::String(now.clone()))?;
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(
&path,
"Trashed at",
FrontmatterValue::String(now.clone()),
)?;
count += 1;
}
Ok(count)
@@ -173,6 +240,7 @@ async fn github_create_repo(
#[tauri::command]
fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
let local_path = expand_tilde(&local_path);
github::clone_repo(&url, &token, &local_path)
}
@@ -198,12 +266,23 @@ async fn search_vault(
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
struct WsBridgeChild(Mutex<Option<Child>>);
#[tauri::command]
async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || mcp::register_mcp(&vault_path))
.await
.map_err(|e| format!("Registration task failed: {e}"))?
}
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
@@ -229,14 +308,55 @@ fn run_startup_tasks() {
"Migrated is_a to type on startup",
vault::migrate_is_a_to_type(vp_str),
);
// Register Laputa MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
Ok(status) => log::info!("MCP registration: {status}"),
Err(e) => log::warn!("MCP registration failed: {e}"),
}
}
#[cfg(test)]
mod tests {}
mod tests {
use super::*;
#[test]
fn expand_tilde_with_subpath() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~/Documents/vault");
assert_eq!(result, format!("{}/Documents/vault", home.display()));
}
#[test]
fn expand_tilde_alone() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~");
assert_eq!(result, home.to_string_lossy());
}
#[test]
fn expand_tilde_noop_for_absolute_path() {
let result = expand_tilde("/usr/local/bin");
assert_eq!(result, "/usr/local/bin");
}
#[test]
fn expand_tilde_noop_for_relative_path() {
let result = expand_tilde("some/relative/path");
assert_eq!(result, "some/relative/path");
}
#[test]
fn expand_tilde_noop_for_tilde_in_middle() {
let result = expand_tilde("/home/~user/path");
assert_eq!(result, "/home/~user/path");
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(WsBridgeChild(Mutex::new(None)))
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -264,6 +384,22 @@ pub fn run() {
run_startup_tasks();
// Spawn the MCP WebSocket bridge for the default vault
{
use tauri::Manager;
let vault_path = dirs::home_dir()
.map(|h| h.join("Laputa"))
.unwrap_or_default();
let vp_str = vault_path.to_string_lossy().to_string();
match mcp::spawn_ws_bridge(&vp_str) {
Ok(child) => {
let state: tauri::State<'_, WsBridgeChild> = app.state();
*state.0.lock().unwrap() = Some(child);
}
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -296,8 +432,23 @@ pub fn run() {
github_device_flow_start,
github_device_flow_poll,
github_get_user,
search_vault
search_vault,
create_getting_started_vault,
check_vault_exists,
get_default_vault_path,
register_mcp_tools
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
use tauri::Manager;
if let tauri::RunEvent::Exit = event {
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut guard = state.0.lock().unwrap();
if let Some(ref mut child) = *guard {
let _ = child.kill();
log::info!("ws-bridge child process killed on exit");
}
}
});
}

310
src-tauri/src/mcp.rs Normal file
View File

@@ -0,0 +1,310 @@
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let output = Command::new("which")
.arg("node")
.output()
.map_err(|e| format!("Failed to run `which node`: {e}"))?;
if !output.status.success() {
return Err("node not found in PATH".into());
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(PathBuf::from(path))
}
/// Resolve the path to `mcp-server/`.
///
/// In dev mode, uses `CARGO_MANIFEST_DIR` (set at compile time).
/// In release mode, navigates from the current executable.
pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("mcp-server");
if dev_path.join("ws-bridge.js").exists() {
return Ok(std::fs::canonicalize(&dev_path).unwrap_or(dev_path));
}
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
let release_path = exe
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("mcp-server"))
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
if release_path.join("ws-bridge.js").exists() {
return Ok(release_path);
}
Err(format!(
"mcp-server not found at {} or {}",
dev_path.display(),
release_path.display()
))
}
/// Spawn the WebSocket bridge as a child process.
pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
let node = find_node()?;
let server_dir = mcp_server_dir()?;
let script = server_dir.join("ws-bridge.js");
let child = Command::new(node)
.arg(&script)
.env("VAULT_PATH", vault_path)
.env("WS_PORT", "9710")
.env("WS_UI_PORT", "9711")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
log::info!("ws-bridge spawned (pid: {})", child.id());
Ok(child)
}
/// Build the MCP server entry JSON for a given vault path and index.js path.
fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
serde_json::json!({
"command": "node",
"args": [index_js],
"env": { "VAULT_PATH": vault_path }
})
}
/// Write MCP registration to a list of config file paths.
/// Returns "registered" on first registration, "updated" if already present.
fn register_mcp_to_configs(
entry: &serde_json::Value,
config_paths: &[PathBuf],
) -> String {
let mut status = "registered";
for config_path in config_paths {
match upsert_mcp_config(config_path, entry) {
Ok(true) => status = "updated",
Ok(false) => {}
Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e),
}
}
status.to_string()
}
/// Register Laputa as an MCP server in Claude Code and Cursor config files.
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let server_dir = mcp_server_dir()?;
let index_js = server_dir
.join("index.js")
.to_string_lossy()
.into_owned();
let entry = build_mcp_entry(&index_js, vault_path);
let configs: Vec<PathBuf> = [
dirs::home_dir().map(|h| h.join(".claude").join("mcp.json")),
dirs::home_dir().map(|h| h.join(".cursor").join("mcp.json")),
]
.into_iter()
.flatten()
.collect();
Ok(register_mcp_to_configs(&entry, &configs))
}
/// Insert or update the "laputa" entry in an MCP config file.
fn upsert_mcp_config(
config_path: &Path,
entry: &serde_json::Value,
) -> Result<bool, String> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
}
let mut config: serde_json::Value = if config_path.exists() {
let raw = std::fs::read_to_string(config_path)
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
serde_json::from_str(&raw)
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))?
} else {
serde_json::json!({})
};
let servers = config
.as_object_mut()
.ok_or("Config is not a JSON object")?
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let was_update = servers.get("laputa").is_some();
servers
.as_object_mut()
.ok_or("mcpServers is not a JSON object")?
.insert("laputa".to_string(), entry.clone());
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(config_path, json)
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?;
Ok(was_update)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_mcp_entry_produces_correct_json() {
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
assert_eq!(entry["command"], "node");
assert_eq!(entry["args"][0], "/path/to/index.js");
assert_eq!(entry["env"]["VAULT_PATH"], "/my/vault");
}
#[test]
fn upsert_creates_new_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/test/vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(!was_update);
let raw = std::fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
assert_eq!(
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
"/test/vault"
);
}
#[test]
fn upsert_updates_existing_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry1 = build_mcp_entry("/test/index.js", "/vault/v1");
upsert_mcp_config(&config_path, &entry1).unwrap();
let entry2 = build_mcp_entry("/test/index.js", "/vault/v2");
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
assert!(was_update);
let raw = std::fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
"/vault/v2"
);
}
#[test]
fn upsert_preserves_other_servers() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let existing = serde_json::json!({
"mcpServers": {
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(
&config_path,
serde_json::to_string(&existing).unwrap(),
)
.unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(config["mcpServers"]["other-server"].is_object());
assert!(config["mcpServers"]["laputa"].is_object());
}
#[test]
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
assert!(config_path.exists());
}
#[test]
fn register_mcp_to_configs_returns_registered_for_new() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("claude").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "registered");
}
#[test]
fn register_mcp_to_configs_returns_updated_for_existing() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
// First call
register_mcp_to_configs(&entry, &[config.clone()]);
// Second call
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "updated");
}
#[test]
fn find_node_returns_valid_path() {
let node = find_node().unwrap();
assert!(node.exists(), "node binary should exist at {:?}", node);
assert!(
node.to_string_lossy().contains("node"),
"path should contain 'node': {:?}",
node
);
}
#[test]
fn mcp_server_dir_resolves_in_dev() {
let dir = mcp_server_dir().unwrap();
assert!(dir.join("ws-bridge.js").exists());
assert!(dir.join("index.js").exists());
assert!(dir.join("vault.js").exists());
}
#[test]
fn spawn_ws_bridge_starts_and_can_be_killed() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().to_str().unwrap();
let mut child = spawn_ws_bridge(vault_path).unwrap();
assert!(child.id() > 0, "child process should have a valid PID");
// Clean up: kill the spawned process
child.kill().unwrap();
child.wait().unwrap();
}
#[test]
fn register_mcp_to_configs_writes_multiple_configs() {
let tmp = tempfile::tempdir().unwrap();
let claude_cfg = tmp.path().join("claude").join("mcp.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
register_mcp_to_configs(&entry, &[claude_cfg.clone(), cursor_cfg.clone()]);
assert!(claude_cfg.exists());
assert!(cursor_cfg.exists());
let raw = std::fs::read_to_string(&claude_cfg).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
}
}

View File

@@ -14,6 +14,9 @@ const VIEW_EDITOR_LIST: &str = "view-editor-list";
const VIEW_ALL: &str = "view-all";
const VIEW_TOGGLE_INSPECTOR: &str = "view-toggle-inspector";
const VIEW_COMMAND_PALETTE: &str = "view-command-palette";
const VIEW_ZOOM_IN: &str = "view-zoom-in";
const VIEW_ZOOM_OUT: &str = "view-zoom-out";
const VIEW_ZOOM_RESET: &str = "view-zoom-reset";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
@@ -26,6 +29,9 @@ const CUSTOM_IDS: &[&str] = &[
VIEW_ALL,
VIEW_TOGGLE_INSPECTOR,
VIEW_COMMAND_PALETTE,
VIEW_ZOOM_IN,
VIEW_ZOOM_OUT,
VIEW_ZOOM_RESET,
];
/// IDs of menu items that should be disabled when no note tab is active.
@@ -115,6 +121,18 @@ fn build_view_menu(app: &App) -> MenuResult {
.id(VIEW_COMMAND_PALETTE)
.accelerator("CmdOrCtrl+K")
.build(app)?;
let zoom_in = MenuItemBuilder::new("Zoom In")
.id(VIEW_ZOOM_IN)
.accelerator("CmdOrCtrl+=")
.build(app)?;
let zoom_out = MenuItemBuilder::new("Zoom Out")
.id(VIEW_ZOOM_OUT)
.accelerator("CmdOrCtrl+-")
.build(app)?;
let zoom_reset = MenuItemBuilder::new("Actual Size")
.id(VIEW_ZOOM_RESET)
.accelerator("CmdOrCtrl+0")
.build(app)?;
Ok(SubmenuBuilder::new(app, "View")
.item(&editor_only)
@@ -123,6 +141,10 @@ fn build_view_menu(app: &App) -> MenuResult {
.separator()
.item(&toggle_inspector)
.separator()
.item(&zoom_in)
.item(&zoom_out)
.item(&zoom_reset)
.separator()
.item(&command_palette)
.build()?)
}
@@ -192,6 +214,9 @@ mod tests {
"view-all",
"view-toggle-inspector",
"view-command-palette",
"view-zoom-in",
"view-zoom-out",
"view-zoom-reset",
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");

View File

@@ -0,0 +1,396 @@
use std::fs;
use std::path::{Path, PathBuf};
/// Default location for the Getting Started vault.
pub fn default_vault_path() -> Result<PathBuf, String> {
dirs::document_dir()
.map(|d| d.join("Laputa"))
.ok_or_else(|| "Could not determine Documents directory".to_string())
}
/// Check whether a vault path exists on disk.
pub fn vault_exists(path: &str) -> bool {
Path::new(path).is_dir()
}
struct SampleFile {
rel_path: &'static str,
content: &'static str,
}
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",
},
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",
},
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",
},
SampleFile {
rel_path: "type/topic.md",
content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
},
SampleFile {
rel_path: "note/welcome-to-laputa.md",
content: r#"---
Is A: Note
Related to:
- "[[note/editor-basics]]"
- "[[note/using-properties]]"
- "[[note/wiki-links-and-relationships]]"
---
# Welcome to Laputa
Welcome to your new knowledge vault! Laputa helps you organize your thoughts, projects, and relationships using **wiki-linked markdown files**.
## How it works
Every note is a markdown file with optional YAML frontmatter at the top. Notes live in folders that define their **type** — a file in the `project/` folder is automatically a Project, a file in `person/` is a Person, and so on.
## What to explore
- [[note/editor-basics]] — Learn about headings, lists, checkboxes, and formatting
- [[note/using-properties]] — See how frontmatter properties work (status, dates, relationships)
- [[note/wiki-links-and-relationships]] — Connect your notes with `[[wiki-links]]`
- [[project/sample-project]] — A sample project with relationships and status
- [[person/sample-collaborator]] — A sample person entry
## Tips
- Press **⌘P** to quick-open any note by title
- Press **⌘K** to open the command palette
- Press **⌘N** to create a new note
- Use the **sidebar** on the left to browse by type
- Use the **inspector** on the right to edit properties and see backlinks
"#,
},
SampleFile {
rel_path: "note/editor-basics.md",
content: r#"---
Is A: Note
Related to: "[[note/welcome-to-laputa]]"
---
# Editor Basics
Laputa uses a rich markdown editor. Here are the key formatting features:
## Headings
Use `#` for headings. The first H1 heading becomes the note's title.
## Lists
- Bullet lists use `-` or `*`
- They can be nested
- Like this
- And this
1. Numbered lists work too
2. Just start with a number
## Checkboxes
- [x] Completed task
- [ ] Pending task
- [ ] Another thing to do
## Text formatting
You can use **bold**, *italic*, `inline code`, and ~~strikethrough~~ text.
## Code blocks
```javascript
function hello() {
console.log("Hello from Laputa!");
}
```
## Blockquotes
> "The best way to have a good idea is to have lots of ideas." — Linus Pauling
"#,
},
SampleFile {
rel_path: "note/using-properties.md",
content: r#"---
Is A: Note
Status: Active
Related to:
- "[[note/welcome-to-laputa]]"
- "[[note/wiki-links-and-relationships]]"
---
# Using Properties
Every note can have **properties** defined in the YAML frontmatter at the top of the file. Properties appear in the inspector panel on the right side of the screen.
## Common properties
- **Is A** — 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
- **Owner** — The person responsible
## How to edit properties
1. Open the **inspector panel** (right side)
2. Click on any property value to edit it
3. For relationship fields, type `[[` to search for notes
4. Use the **+ Add property** button to add custom fields
## Custom properties
You can add any custom property. If the value contains `[[wiki-links]]`, Laputa will treat it as a relationship and show it as a clickable link in the inspector.
"#,
},
SampleFile {
rel_path: "note/wiki-links-and-relationships.md",
content: r#"---
Is A: Note
Related to:
- "[[note/welcome-to-laputa]]"
- "[[note/using-properties]]"
---
# Wiki-Links and Relationships
Wiki-links are the core of Laputa's knowledge graph. They let you connect any note to any other note using the `[[double bracket]]` syntax.
## Creating links
Type `[[` in the editor to open the link suggestion menu. Start typing to search for a note, then select it. The link will look like this: [[note/welcome-to-laputa]].
## Backlinks
When note A links to note B, note B automatically shows a **backlink** to note A in the inspector panel. This means you never have to manually maintain bidirectional links.
## Relationships in frontmatter
You can also define relationships in the frontmatter:
```yaml
Belongs to: "[[project/sample-project]]"
Related to:
- "[[note/editor-basics]]"
- "[[note/using-properties]]"
```
These appear as clickable pills in the inspector and are navigable with a single click.
## Building your knowledge graph
Over time, your wiki-links form a rich web of connections. Use the **Referenced By** section in the inspector to discover how notes relate to each other.
"#,
},
SampleFile {
rel_path: "project/sample-project.md",
content: r#"---
Is A: Project
Status: Active
Owner: "[[person/sample-collaborator]]"
Related to: "[[topic/getting-started]]"
---
# Sample Project
This is an example project to show how projects work in Laputa.
## Overview
Projects are time-bounded efforts with clear goals. They have a **status** (Active, Paused, Done, Dropped) and can be linked to people, topics, and other notes.
## Goals
- [ ] Explore the Laputa editor and its features
- [ ] Create your first custom note
- [ ] Link notes together using wiki-links
- [ ] Try editing properties in the inspector
## Notes
This project is owned by [[person/sample-collaborator]] and relates to [[topic/getting-started]]. You can see these relationships in the inspector panel on the right.
"#,
},
SampleFile {
rel_path: "person/sample-collaborator.md",
content: r#"---
Is A: Person
---
# Sample Collaborator
This is an example person entry. In your vault, you might create entries for colleagues, friends, mentors, or anyone you interact with regularly.
## What person entries are for
- Track who owns which projects
- Record meeting notes linked to specific people
- Build a network of relationships between people, projects, and topics
## Connections
This person is the owner of [[project/sample-project]]. Check the **Referenced By** section in the inspector to see all notes that link back here.
"#,
},
SampleFile {
rel_path: "topic/getting-started.md",
content: r#"---
Is A: Topic
---
# Getting Started
This topic groups notes related to learning and getting started with Laputa.
## Related notes
- [[note/welcome-to-laputa]] — Start here for an overview
- [[note/editor-basics]] — Formatting and editor features
- [[note/using-properties]] — Frontmatter and the inspector
- [[note/wiki-links-and-relationships]] — Building your knowledge graph
- [[project/sample-project]] — A sample project with relationships
"#,
},
];
/// Create the Getting Started vault at the specified path.
/// Returns the absolute path to the created vault.
pub fn create_getting_started_vault(target_path: &str) -> Result<String, String> {
let vault_dir = Path::new(target_path);
if vault_dir.exists()
&& vault_dir
.read_dir()
.map(|mut d| d.next().is_some())
.unwrap_or(false)
{
return Err(format!(
"Directory already exists and is not empty: {}",
target_path
));
}
fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
for sample in SAMPLE_FILES {
let file_path = vault_dir.join(sample.rel_path);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
fs::write(&file_path, sample.content)
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
}
Ok(vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_vault_path_is_in_documents() {
let path = default_vault_path().unwrap();
let path_str = path.to_string_lossy();
assert!(path_str.contains("Documents"));
assert!(path_str.ends_with("Laputa"));
}
#[test]
fn test_vault_exists_false_for_missing() {
assert!(!vault_exists("/nonexistent/vault/path/abc123"));
}
#[test]
fn test_create_getting_started_vault_creates_files() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("test-vault");
let result = create_getting_started_vault(vault_path.to_str().unwrap());
assert!(result.is_ok());
// Verify key files exist
assert!(vault_path.join("note/welcome-to-laputa.md").exists());
assert!(vault_path.join("note/editor-basics.md").exists());
assert!(vault_path.join("note/using-properties.md").exists());
assert!(vault_path
.join("note/wiki-links-and-relationships.md")
.exists());
assert!(vault_path.join("project/sample-project.md").exists());
assert!(vault_path.join("person/sample-collaborator.md").exists());
assert!(vault_path.join("topic/getting-started.md").exists());
assert!(vault_path.join("type/project.md").exists());
assert!(vault_path.join("type/note.md").exists());
assert!(vault_path.join("type/person.md").exists());
assert!(vault_path.join("type/topic.md").exists());
}
#[test]
fn test_create_vault_rejects_non_empty_directory() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("non-empty");
fs::create_dir_all(&vault_path).unwrap();
fs::write(vault_path.join("existing.md"), "# Existing").unwrap();
let result = create_getting_started_vault(vault_path.to_str().unwrap());
assert!(result.is_err());
assert!(result.unwrap_err().contains("not empty"));
}
#[test]
fn test_create_vault_allows_empty_directory() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("empty-dir");
fs::create_dir_all(&vault_path).unwrap();
let result = create_getting_started_vault(vault_path.to_str().unwrap());
assert!(result.is_ok());
}
#[test]
fn test_sample_files_have_valid_frontmatter() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("validation-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
for sample in SAMPLE_FILES {
let file_path = vault_path.join(sample.rel_path);
let content = fs::read_to_string(&file_path).unwrap();
// Verify each file has frontmatter delimiters
assert!(
content.starts_with("---\n"),
"{} should start with frontmatter",
sample.rel_path
);
assert!(
content.matches("---").count() >= 2,
"{} should have closing frontmatter delimiter",
sample.rel_path
);
}
}
#[test]
fn test_sample_files_parseable_as_vault_entries() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("parse-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let entries = crate::vault::scan_vault(&vault_path).unwrap();
assert_eq!(entries.len(), SAMPLE_FILES.len());
}
}

View File

@@ -1,4 +1,5 @@
mod cache;
mod getting_started;
mod image;
mod migration;
mod parsing;
@@ -6,6 +7,7 @@ mod rename;
mod trash;
pub use cache::scan_vault_cached;
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::save_image;
pub use migration::migrate_is_a_to_type;
pub use rename::{rename_note, RenameResult};
@@ -72,7 +74,7 @@ pub struct VaultEntry {
/// Intermediate struct to capture YAML frontmatter fields.
#[derive(Debug, Deserialize, Default)]
struct Frontmatter {
#[serde(rename = "Is A")]
#[serde(rename = "Is A", alias = "type")]
is_a: Option<StringOrList>,
#[serde(default)]
aliases: Option<StringOrList>,
@@ -133,6 +135,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
/// Only skip keys that can never contain wikilinks.
const SKIP_KEYS: &[&str] = &[
"is a",
"type",
"aliases",
"status",
"cadence",
@@ -966,6 +969,39 @@ References:
assert_eq!(entry.is_a, Some("Type".to_string()));
}
// --- type key (post-migration) tests ---
#[test]
fn test_parse_type_key_lowercase() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Project\n---\n# My Project\n";
let entry = parse_test_entry(&dir, "project/my-project.md", content);
assert_eq!(entry.is_a, Some("Project".to_string()));
}
#[test]
fn test_type_key_generates_type_relationship() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Person\n---\n# Alice\n";
let entry = parse_test_entry(&dir, "person/alice.md", content);
assert_eq!(
entry.relationships.get("Type").unwrap(),
&vec!["[[type/person]]".to_string()]
);
}
#[test]
fn test_type_key_not_in_relationships_as_generic() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Note\nHas:\n - \"[[task/foo]]\"\n---\n# Test\n";
let entry = parse_test_entry(&dir, "note/test.md", content);
// "type" key itself should not appear as a relationship (it's in SKIP_KEYS)
// Only "Has" and the auto-generated "Type" should be relationships
assert_eq!(entry.relationships.len(), 2);
assert!(entry.relationships.get("Has").is_some());
assert!(entry.relationships.get("Type").is_some());
}
// --- outgoing_links tests ---
#[test]

View File

@@ -48,9 +48,9 @@
"plugins": {
"updater": {
"endpoints": [
"https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json"
"https://refactoringhq.github.io/laputa-app/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQxQjNDRUVGNTgxM0Q2RDQKUldUVTFoTlk3ODZ6MGNYSml6aWF4SzBvMTFvdDJFQWQwZHZ0d0lDeW9LekRHZ3h5MnJsb2lpenEK"
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFQzlGQ0RFM0E1MTIzNDkKUldSSkkxRTYzdnpKVG13M0Zwd3M1RzErbWhJeEhBQUQyaG90bHBtMkNzMm1MNERZRlpXSGFRMTUK"
}
}
}

View File

@@ -70,6 +70,8 @@ vi.mock('./mock-tauri', () => ({
if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null }
if (cmd === 'git_pull') return { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }
if (cmd === 'save_settings') return null
if (cmd === 'check_vault_exists') return true
if (cmd === 'get_default_vault_path') return '/Users/mock/Documents/Laputa'
return null
}),
addMockEntry: vi.fn(),
@@ -120,8 +122,9 @@ describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset view mode between tests so sidebar starts visible
// Reset view mode and onboarding state between tests
localStorage.removeItem('laputa-view-mode')
localStorage.removeItem('laputa_welcome_dismissed')
})
it('renders the four-panel layout', async () => {

View File

@@ -12,6 +12,8 @@ import { CommitDialog } from './components/CommitDialog'
import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { useMcpRegistration } from './hooks/useMcpRegistration'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
@@ -26,6 +28,8 @@ import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater } from './hooks/useUpdater'
import { useNavigationHistory } from './hooks/useNavigationHistory'
import { useAutoSync } from './hooks/useAutoSync'
import { useZoom } from './hooks/useZoom'
import { useOnboarding } from './hooks/useOnboarding'
import { UpdateBanner } from './components/UpdateBanner'
import { setApiKey } from './utils/ai-chat'
import { extractOutgoingLinks } from './utils/wikilinks'
@@ -76,13 +80,14 @@ function useEditorSaveWithLinks(config: {
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
setToastMessage: (msg: string | null) => void
onAfterSave: () => void
onNotePersisted?: (path: string) => void
}) {
const { updateContent, updateEntry } = config
const saveContent = useCallback((path: string, content: string) => {
updateContent(path, content)
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
}, [updateContent, updateEntry])
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave })
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
@@ -111,13 +116,18 @@ function App() {
onToast: (msg) => setToastMessage(msg),
})
const vault = useVaultLoader(vaultSwitcher.vaultPath)
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const vault = useVaultLoader(resolvedPath)
const { settings, saveSettings } = useSettings()
useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key])
useMcpRegistration(resolvedPath, setToastMessage)
const autoSync = useAutoSync({
vaultPath: vaultSwitcher.vaultPath,
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: vault.reloadVault,
onConflict: (files) => {
@@ -127,7 +137,11 @@ function App() {
onToast: (msg) => setToastMessage(msg),
})
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 })
// Ref bridges handleContentChange (created after notes) into useNoteActions.
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content) })
const navHistory = useNavigationHistory()
@@ -198,10 +212,21 @@ function App() {
}
}, [handleGoBack, handleGoForward])
const { handleSave, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
setTabs: notes.setTabs, setToastMessage, onAfterSave: vault.loadModifiedFiles,
onNotePersisted: vault.clearUnsaved,
})
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
const handleSave = useCallback(async () => {
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path)
? { path: activeTab.entry.path, content: activeTab.content }
: undefined
await handleSaveRaw(fallback)
}, [handleSaveRaw, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
@@ -220,12 +245,21 @@ function App() {
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
await savePendingForPath(path)
await notes.handleRenameNote(path, newTitle, vaultSwitcher.vaultPath, vault.replaceEntry).then(vault.loadModifiedFiles)
}, [notes, vaultSwitcher.vaultPath, vault, savePendingForPath])
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
}, [notes, resolvedPath, vault, savePendingForPath])
/** H1→title sync: update VaultEntry.title and tab entry in memory. */
const handleTitleSync = useCallback((path: string, newTitle: string) => {
vault.updateEntry(path, { title: newTitle })
notes.setTabs(prev => prev.map(t =>
t.entry.path === path ? { ...t, entry: { ...t.entry, title: newTitle } } : t
))
}, [vault, notes])
const bulkActions = useBulkActions(entryActions, setToastMessage)
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
const zoom = useZoom()
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
@@ -234,12 +268,16 @@ function App() {
modifiedCount: vault.modifiedFiles.length, selection,
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
onSearch: dialogs.openSearch,
onCreateNote: notes.handleCreateNoteImmediate, onSave: handleSave,
onCreateNote: notes.handleCreateNoteImmediate,
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: handleSave,
onOpenSettings: dialogs.openSettings,
onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote,
onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab,
onSelectNote: notes.handleSelectNote,
@@ -251,6 +289,35 @@ function App() {
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
// Show welcome/onboarding screen when vault doesn't exist
if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') {
const defaultPath = onboarding.state.defaultPath
return (
<div className="app-shell">
<WelcomeScreen
mode={onboarding.state.status === 'welcome' ? 'welcome' : 'vault-missing'}
missingPath={onboarding.state.status === 'vault-missing' ? onboarding.state.vaultPath : undefined}
defaultVaultPath={defaultPath}
onCreateVault={onboarding.handleCreateVault}
onOpenFolder={onboarding.handleOpenFolder}
creating={onboarding.creating}
error={onboarding.error}
/>
</div>
)
}
// Show loading spinner while checking vault
if (onboarding.state.status === 'loading') {
return (
<div className="app-shell">
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading</span>
</div>
</div>
)
}
return (
<div className="app-shell">
<UpdateBanner status={updateStatus} actions={updateActions} />
@@ -266,7 +333,7 @@ function App() {
{noteListVisible && (
<>
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} getNoteStatus={vault.getNoteStatus} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
@@ -297,25 +364,27 @@ function App() {
onAddProperty={notes.handleAddProperty}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={vaultSwitcher.vaultPath}
vaultPath={resolvedPath}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
onRenameTab={handleRenameTab}
onContentChange={handleContentChange}
onTitleSync={handleTitleSync}
canGoBack={navHistory.canGoBack}
canGoForward={navHistory.canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
/>
</div>
</div>
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
<SearchPanel open={dialogs.showSearch} vaultPath={vaultSwitcher.vaultPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
@@ -325,6 +394,7 @@ function App() {
onClose={dialogs.closeGitHubVault}
onVaultCloned={vaultSwitcher.handleVaultCloned}
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
/>
</div>
)

View File

@@ -1012,7 +1012,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('\u2713 Yes')).toBeInTheDocument()
})
it('stores actual boolean value when adding boolean property', () => {
it('stores actual boolean value when adding boolean property', { timeout: 15_000 }, () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -1034,7 +1034,7 @@ describe('DynamicPropertiesPanel', () => {
expect(onAddProperty).toHaveBeenCalledWith('published', true)
})
it('shows date picker trigger when date type selected', () => {
it('shows date picker trigger when date type selected', { timeout: 15_000 }, () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -1050,7 +1050,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('Pick a date\u2026')).toBeInTheDocument()
})
it('shows status dropdown when status type selected', () => {
it('shows status dropdown when status type selected', { timeout: 15_000 }, () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}

View File

@@ -1,15 +1,18 @@
import { useMemo, useState, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { parseFrontmatter } from '../utils/frontmatter'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link } from 'lucide-react'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import { countWords } from '../utils/wikilinks'
import {
type PropertyDisplayMode,
@@ -22,7 +25,8 @@ import {
detectPropertyType,
} from '../utils/propertyTypes'
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { SUGGESTED_STATUSES } from '../utils/statusStyles'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
// Keys that are relationships (contain wikilinks)
export const RELATIONSHIP_KEYS = new Set([
@@ -94,6 +98,74 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
)
}
function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }: {
propKey: string; value: string[]; isEditing: boolean; vaultTags: string[]
onSave: (key: string, items: string[]) => void; onStartEdit: (key: string | null) => void
}) {
const handleToggle = useCallback((tag: string) => {
const idx = value.indexOf(tag)
const next = idx >= 0 ? value.filter((_, i) => i !== idx) : [...value, tag]
onSave(propKey, next)
}, [propKey, value, onSave])
const handleRemove = useCallback((tag: string) => {
onSave(propKey, value.filter(t => t !== tag))
}, [propKey, value, onSave])
return (
<span className="relative inline-flex min-w-0 flex-wrap items-center gap-1">
{value.map(tag => {
const style = getTagStyle(tag)
return (
<span
key={tag}
className="group/tag relative inline-flex items-center overflow-hidden rounded-full"
style={{ backgroundColor: style.bg, padding: '1px 6px', maxWidth: 120 }}
>
<span
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
style={{
color: style.color,
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
overflow: 'hidden',
whiteSpace: 'nowrap' as const,
}}
>
{tag}
</span>
<button
className="ml-0.5 max-w-0 overflow-hidden border-none bg-transparent p-0 leading-none opacity-0 transition-all duration-150 group-hover/tag:max-w-[14px] group-hover/tag:opacity-100"
style={{ color: style.color, fontSize: 10, flexShrink: 0 }}
onClick={() => handleRemove(tag)}
title={`Remove ${tag}`}
>
&times;
</button>
</span>
)
})}
<button
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-dashed border-muted-foreground bg-transparent text-[10px] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
onClick={() => onStartEdit(propKey)}
title="Add tag"
data-testid="tags-add-button"
>+</button>
{isEditing && (
<TagsDropdown
selectedTags={value}
vaultTags={vaultTags}
onToggle={handleToggle}
onClose={() => onStartEdit(null)}
/>
)}
</span>
)
}
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
return (
<button
@@ -149,7 +221,7 @@ function DateValue({ value, onSave }: {
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ''}`}>{formatted || 'Pick a date\u2026'}</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end" data-testid="date-picker-popover">
<PopoverContent className="w-auto p-0" align="end" side="left" data-testid="date-picker-popover">
<Calendar
mode="single"
selected={selectedDate}
@@ -180,6 +252,7 @@ const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
{ value: 'boolean', label: 'Boolean' },
{ value: 'status', label: 'Status' },
{ value: 'url', label: 'URL' },
{ value: 'tags', label: 'Tags' },
]
function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
@@ -187,7 +260,19 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
onSelect: (key: string, mode: PropertyDisplayMode | null) => void
}) {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const positionMenu = useCallback((node: HTMLDivElement | null) => {
if (!node) return
const el = triggerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const menuW = 140
let left = rect.right - menuW
if (left < 8) left = 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
const handleSelect = (mode: PropertyDisplayMode) => {
if (mode === autoMode) {
@@ -199,8 +284,9 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
}
return (
<div ref={containerRef} className="relative">
<div className="relative">
<button
ref={triggerRef}
className="flex h-4 w-4 items-center justify-center rounded border-none bg-transparent p-0 text-[10px] leading-none text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/prop:opacity-100"
onClick={() => setOpen(!open)}
title="Change display mode"
@@ -208,47 +294,53 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
>
{'\u25BE'}
</button>
{open && (
{open && createPortal(
<>
<div className="fixed inset-0 z-[12000]" onClick={() => setOpen(false)} />
<div
className="absolute right-0 top-full z-[12001] mt-1 min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
ref={positionMenu}
className="fixed z-[12001] min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
data-testid="display-mode-menu"
>
{DISPLAY_MODE_OPTIONS.map(opt => (
<button
key={opt.value}
className="flex w-full items-center gap-2 border-none bg-transparent px-3 py-1.5 text-left text-[12px] text-foreground transition-colors hover:bg-muted"
onClick={() => handleSelect(opt.value)}
data-testid={`display-mode-option-${opt.value}`}
>
<span className="w-3 text-center text-[10px]">
{currentMode === opt.value ? '\u2713' : ''}
</span>
{opt.label}
{opt.value === autoMode && (
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
)}
</button>
))}
{DISPLAY_MODE_OPTIONS.map(opt => {
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
return (
<button
key={opt.value}
className="flex w-full items-center gap-2 border-none bg-transparent px-3 py-1.5 text-left text-[12px] text-foreground transition-colors hover:bg-muted"
onClick={() => handleSelect(opt.value)}
data-testid={`display-mode-option-${opt.value}`}
>
<span className="w-3 text-center text-[10px]">
{currentMode === opt.value ? '\u2713' : ''}
</span>
<OptIcon className="size-3.5 text-muted-foreground" />
{opt.label}
{opt.value === autoMode && (
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
)}
</button>
)
})}
</div>
</>
</>,
document.body
)}
</div>
)
}
const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link,
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag,
}
const ADD_INPUT_CLASS = "h-[26px] min-w-0 flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const boolVal = value.toLowerCase() === 'true'
return (
<button
className="h-[26px] min-w-0 flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-secondary-foreground transition-colors hover:bg-accent"
className="h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-secondary-foreground transition-colors hover:bg-accent"
onClick={() => onChange(boolVal ? 'false' : 'true')}
data-testid="add-property-boolean-toggle"
>
@@ -264,7 +356,7 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string
<Popover>
<PopoverTrigger asChild>
<button
className="inline-flex h-[26px] min-w-0 flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
className="inline-flex h-[26px] min-w-[60px] flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
data-testid="add-property-date-trigger"
>
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
@@ -273,7 +365,7 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<PopoverContent className="w-auto p-0" align="start" side="left">
<Calendar
mode="single"
selected={selectedDate}
@@ -286,23 +378,25 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string
}
function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onChange: (v: string) => void; vaultStatuses: string[] }) {
const allStatuses = [...new Set([...vaultStatuses, ...SUGGESTED_STATUSES])]
const [showDropdown, setShowDropdown] = useState(false)
return (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger
size="sm"
className="h-[26px] min-w-0 flex-1 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
style={{ fontSize: 12, borderRadius: 4 }}
<span className="relative inline-flex min-w-[60px] flex-1 items-center">
<button
className="inline-flex h-[26px] min-w-[60px] flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
onClick={() => setShowDropdown(true)}
data-testid="add-property-status-trigger"
>
<SelectValue placeholder="Status\u2026" />
</SelectTrigger>
<SelectContent>
{allStatuses.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
{value ? <StatusPill status={value} /> : <span className="text-muted-foreground">Status{'\u2026'}</span>}
</button>
{showDropdown && (
<StatusDropdown
value={value}
vaultStatuses={vaultStatuses}
onSave={(v) => { onChange(v); setShowDropdown(false) }}
onCancel={() => setShowDropdown(false)}
/>
)}
</span>
)
}
@@ -314,6 +408,11 @@ function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultS
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
case 'date': return <AddDateInput value={value} onChange={onChange} />
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
case 'tags': return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
default: return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
@@ -342,22 +441,22 @@ function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
}
return (
<div className="mt-1 flex items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
<input
className="h-[26px] w-[90px] shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
className="h-[26px] w-20 shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Property name" value={newKey}
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
/>
<Select value={displayMode} onValueChange={(v) => handleModeChange(v as PropertyDisplayMode)}>
<SelectTrigger
size="sm"
className="h-[26px] w-[82px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
className="h-[26px] w-[72px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
style={{ fontSize: 12, borderRadius: 4 }}
data-testid="add-property-type-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="left">
{DISPLAY_MODE_OPTIONS.map(opt => {
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
return (
@@ -389,7 +488,7 @@ const TYPE_NONE = '__none__'
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
if (!isA) return null
return (
<div className="flex items-center justify-between px-1.5">
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
{onNavigate ? (
<button
@@ -404,8 +503,24 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
)
}
function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, onNavigate }: {
function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
type: string; typeColorKeys: Record<string, string | null>; typeIconKeys: Record<string, string | null>
}) {
const Icon = getTypeIcon(type, typeIconKeys[type])
const color = getTypeColor(type, typeColorKeys[type])
return (
<>
{/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */}
<Icon width={14} height={14} style={{ color }} />
{type}
</>
)
}
function TypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, typeIconKeys, onUpdateProperty, onNavigate }: {
isA?: string | null; customColorKey?: string | null; availableTypes: string[]
typeColorKeys: Record<string, string | null>
typeIconKeys: Record<string, string | null>
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
}) {
@@ -417,7 +532,7 @@ function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, o
: availableTypes
return (
<div className="flex items-center justify-between px-1.5" data-testid="type-selector">
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="type-selector">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
<SelectTrigger
@@ -427,11 +542,13 @@ function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, o
>
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper" side="left">
<SelectItem value={TYPE_NONE}>None</SelectItem>
<SelectSeparator />
{options.map(type => (
<SelectItem key={type} value={type}>{type}</SelectItem>
<SelectItem key={type} value={type}>
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
</SelectItem>
))}
</SelectContent>
</Select>
@@ -451,20 +568,21 @@ function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
return 'text'
}
function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate }: {
type SmartCellProps = {
propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean
vaultStatuses: string[]
vaultStatuses: string[]; vaultTags: string[]
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
}) {
}
function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate }: SmartCellProps) {
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
if (Array.isArray(value)) return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(value) : displayMode
switch (resolvedMode) {
case 'status':
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
case 'tags':
return <TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
case 'date':
return <DateValue value={String(value ?? '')} onSave={(v) => onSave(propKey, v)} />
case 'boolean': {
@@ -478,34 +596,45 @@ function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, vaultS
}
}
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
function SmartPropertyValueCell(props: SmartCellProps) {
const { propKey, value, displayMode, isEditing, vaultTags, onSaveList, onStartEdit } = props
if (Array.isArray(value)) {
if (displayMode === 'tags') {
return <TagsValue propKey={propKey} value={value.map(String)} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
}
return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
}
return <ScalarValueCell {...props} />
}
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
vaultStatuses: string[]
vaultStatuses: string[]; vaultTags: string[]
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
onSaveList: (key: string, items: string[]) => void
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
}) {
return (
<div className="group/prop flex items-center justify-between rounded px-1.5 py-0.5 transition-colors hover:bg-muted" data-testid="editable-property">
<span className="font-mono-overline flex shrink-0 items-center gap-1 text-muted-foreground">
{propKey}
<div className="group/prop flex min-w-0 items-center justify-between gap-2 rounded px-1.5 py-0.5 transition-colors hover:bg-muted" data-testid="editable-property">
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
<span className="truncate">{propKey}</span>
{onDelete && (
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">&times;</button>
)}
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
</span>
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
</div>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between px-1.5" data-testid="readonly-property">
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="readonly-property">
<span className="font-mono-overline shrink-0" style={{ color: 'var(--text-muted)' }}>{label}</span>
<span className="text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
</div>
)
}
@@ -547,9 +676,17 @@ function reconcileListUpdate(
function deriveTypeInfo(entries: VaultEntry[] | undefined, entryIsA: string | null) {
const typeEntries = (entries ?? []).filter(e => e.isA === 'Type')
const typeColorKeys: Record<string, string | null> = {}
const typeIconKeys: Record<string, string | null> = {}
for (const e of typeEntries) {
typeColorKeys[e.title] = e.color ?? null
typeIconKeys[e.title] = e.icon ?? null
}
return {
availableTypes: typeEntries.map(e => e.title).sort((a, b) => a.localeCompare(b)),
customColorKey: entryIsA ? (typeEntries.find(e => e.title === entryIsA)?.color ?? null) : null,
customColorKey: entryIsA ? (typeColorKeys[entryIsA] ?? null) : null,
typeColorKeys,
typeIconKeys,
}
}
@@ -561,12 +698,39 @@ 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 {}
const tagsByKey = new Map<string, Set<string>>()
for (const entry of entries) {
const content = allContent[entry.path]
if (!content) continue
mergeArrayFieldsInto(parseFrontmatter(content), tagsByKey)
}
const result: Record<string, string[]> = {}
for (const [key, set] of tagsByKey) result[key] = Array.from(set).sort((a, b) => a.localeCompare(b))
return result
}
function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
return !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value)
}
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
return mode === 'boolean' ? rawValue.toLowerCase() === 'true' : parseNewValue(rawValue)
if (mode === 'boolean') return rawValue.toLowerCase() === 'true'
if (mode === 'tags') {
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
return items
}
return parseNewValue(rawValue)
}
function persistModeOverride(key: string, mode: PropertyDisplayMode | null) {
@@ -578,19 +742,21 @@ interface PropertyPanelDeps {
entries: VaultEntry[] | undefined
entryIsA: string | null
frontmatter: ParsedFrontmatter
allContent: Record<string, string> | undefined
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
}
function usePropertyPanelState(deps: PropertyPanelDeps) {
const { entries, entryIsA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
const { entries, entryIsA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
const [editingKey, setEditingKey] = useState<string | null>(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides())
const { availableTypes, customColorKey } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
const { availableTypes, customColorKey, typeColorKeys, typeIconKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries, allContent), [entries, allContent])
const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter])
const handleSaveValue = useCallback((key: string, newValue: string) => {
@@ -620,19 +786,20 @@ function usePropertyPanelState(deps: PropertyPanelDeps) {
return {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, vaultStatuses, propertyEntries,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
}
}
export function DynamicPropertiesPanel({
entry, content, frontmatter, entries,
entry, content, frontmatter, entries, allContent,
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
@@ -640,21 +807,22 @@ export function DynamicPropertiesPanel({
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, vaultStatuses, propertyEntries,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty })
const wordCount = countWords(content ?? '')
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
{propertyEntries.map(([key, value]) => (
<PropertyRow
key={key} propKey={key} value={value}
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
vaultStatuses={vaultStatuses}
vaultTags={vaultTagsByKey[key] ?? []}
onStartEdit={setEditingKey} onSave={handleSaveValue}
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
onDelete={onDeleteProperty}

View File

@@ -211,7 +211,7 @@ export function TagPillList({
) : (
<span
key={idx}
className="group/pill inline-flex cursor-pointer items-center gap-0.5 rounded-full py-0.5 pl-2 pr-1 transition-colors"
className="group/pill relative inline-flex cursor-pointer items-center rounded-full px-2 py-0.5 transition-colors"
style={{
backgroundColor: 'var(--accent-blue-light)',
color: 'var(--accent-blue)',
@@ -223,8 +223,8 @@ export function TagPillList({
>
{item}
<button
className="ml-0.5 inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border-none bg-transparent p-0 text-[10px] leading-none opacity-0 transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
style={{ color: 'var(--accent-blue)' }}
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 shadow-[-6px_0_4px_-2px_var(--accent-blue-light)] transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
style={{ color: 'var(--accent-blue)', backgroundColor: 'var(--accent-blue-light)' }}
onClick={(e) => {
e.stopPropagation()
handleDeleteItem(idx)

View File

@@ -1,5 +1,6 @@
import { useRef, useEffect, memo } from 'react'
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
import { useRef, useEffect, useCallback, memo } from 'react'
import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap'
import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync'
import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
import { uploadImageFile } from '../hooks/useImageDrop'
@@ -52,10 +53,13 @@ interface EditorProps {
onUnarchiveNote?: (path: string) => void
onRenameTab?: (path: string, newTitle: string) => void
onContentChange?: (path: string, content: string) => void
/** Called when H1→title sync updates the title (debounced). */
onTitleSync?: (path: string, newTitle: string) => void
canGoBack?: boolean
canGoForward?: boolean
onGoBack?: () => void
onGoForward?: () => void
leftPanelsCollapsed?: boolean
}
function EditorEmptyState() {
@@ -76,8 +80,8 @@ export const Editor = memo(function Editor({
showAIChat, onToggleAIChat,
vaultPath,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onRenameTab, onContentChange,
canGoBack, canGoForward, onGoBack, onGoForward,
onRenameTab, onContentChange, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -87,14 +91,29 @@ export const Editor = memo(function Editor({
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
})
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange })
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
const { syncActiveRef, onH1Changed, onManualRename } = useHeadingTitleSync({
activeTabPath,
currentTitle: activeTab?.entry.title ?? null,
onTitleSync: onTitleSync ?? (() => {}),
})
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
tabs, activeTabPath, editor, onContentChange,
onH1Change: onH1Changed, syncActiveRef,
})
useEditorFocus(editor, editorMountedRef)
const handleRenameTabWithSync = useCallback((path: string, newTitle: string) => {
const h1Text = getH1TextFromBlocks(editor.document)
onManualRename(newTitle, h1Text)
onRenameTab?.(path, newTitle)
}, [editor, onManualRename, onRenameTab])
const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({
activeTabPath, onLoadDiff, onLoadDiffAtCommit,
})
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
const isLoadingNewTab = activeTabPath !== null && !activeTab
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
@@ -110,11 +129,12 @@ export const Editor = memo(function Editor({
onCloseTab={onCloseTab}
onCreateNote={onCreateNote}
onReorderTabs={onReorderTabs}
onRenameTab={onRenameTab}
onRenameTab={handleRenameTabWithSync}
canGoBack={canGoBack}
canGoForward={canGoForward}
onGoBack={onGoBack}
onGoForward={onGoForward}
leftPanelsCollapsed={leftPanelsCollapsed}
/>
<div className="flex flex-1 min-h-0">
{tabs.length === 0

View File

@@ -55,6 +55,7 @@ export function EditorRightPanel({
entry={inspectorEntry}
content={inspectorContent}
entries={entries}
allContent={allContent}
gitHistory={gitHistory}
onNavigate={onNavigateWikilink}
onViewCommitDiff={onViewCommitDiff}

View File

@@ -0,0 +1,226 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { GithubLogo, CircleNotch, ArrowClockwise, Copy, Check } from '@phosphor-icons/react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { openExternalUrl } from '../utils/url'
import type { DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types'
function tauriCall<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
}
type OAuthStatus = 'idle' | 'waiting' | 'error'
/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */
function processPollResult(
result: DeviceFlowPollResult,
callbacks: {
onComplete: (token: string) => Promise<void>
onExpired: () => void
onError: (msg: string) => void
},
): 'done' | 'continue' {
if (result.status === 'complete' && result.access_token) {
callbacks.onComplete(result.access_token)
return 'done'
}
if (result.status === 'expired') {
callbacks.onExpired()
return 'done'
}
if (result.status === 'error') {
callbacks.onError(result.error ?? 'Authorization failed.')
return 'done'
}
return 'continue'
}
interface GitHubDeviceFlowProps {
onConnected: (token: string, username: string) => void
}
export function GitHubDeviceFlow({ onConnected }: GitHubDeviceFlowProps) {
const [oauthStatus, setOauthStatus] = useState<OAuthStatus>('idle')
const [userCode, setUserCode] = useState<string | null>(null)
const [verificationUri, setVerificationUri] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const pollingRef = useRef(false)
const deviceCodeRef = useRef<string | null>(null)
const stopPolling = useCallback(() => {
pollingRef.current = false
deviceCodeRef.current = null
}, [])
useEffect(() => {
return () => { pollingRef.current = false }
}, [])
const handleLogin = useCallback(async () => {
setOauthStatus('waiting')
setErrorMessage(null)
setUserCode(null)
try {
const flowStart = await tauriCall<DeviceFlowStart>('github_device_flow_start')
setUserCode(flowStart.user_code)
setVerificationUri(flowStart.verification_uri)
deviceCodeRef.current = flowStart.device_code
openExternalUrl(flowStart.verification_uri).catch(() => {})
pollingRef.current = true
const intervalMs = Math.max(flowStart.interval * 1000, 5000)
const pollLoop = async () => {
while (pollingRef.current && deviceCodeRef.current) {
await new Promise(r => setTimeout(r, intervalMs))
if (!pollingRef.current) break
const result = await tauriCall<DeviceFlowPollResult>('github_device_flow_poll', {
deviceCode: deviceCodeRef.current,
})
const outcome = processPollResult(result, {
onComplete: async (token) => {
const user = await tauriCall<GitHubUser>('github_get_user', { token })
stopPolling()
setOauthStatus('idle')
setUserCode(null)
onConnected(token, user.login)
},
onExpired: () => {
stopPolling()
setOauthStatus('error')
setErrorMessage('Authorization expired. Please try again.')
},
onError: (msg) => {
stopPolling()
setOauthStatus('error')
setErrorMessage(msg)
},
})
if (outcome === 'done') return
}
}
pollLoop().catch(err => {
stopPolling()
setOauthStatus('error')
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.')
})
} catch (err) {
setOauthStatus('error')
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.')
}
}, [onConnected, stopPolling])
const resetOAuth = useCallback(() => {
stopPolling()
setOauthStatus('idle')
setUserCode(null)
setVerificationUri(null)
setErrorMessage(null)
}, [stopPolling])
if (oauthStatus === 'waiting' && userCode) {
return <DeviceCodeView userCode={userCode} verificationUri={verificationUri} onCancel={resetOAuth} />
}
return <LoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} onRetry={errorMessage ? () => { resetOAuth(); handleLogin() } : undefined} />
}
function DeviceCodeView({ userCode, verificationUri, onCancel }: { userCode: string; verificationUri: string | null; onCancel: () => void }) {
const [copied, setCopied] = useState(false)
const handleCopyCode = useCallback(() => {
navigator.clipboard.writeText(userCode).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}).catch(() => {})
}, [userCode])
const handleOpenUrl = useCallback(() => {
if (verificationUri) openExternalUrl(verificationUri).catch(() => {})
}, [verificationUri])
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} data-testid="github-waiting">
<div
className="border border-border rounded px-4 py-3"
style={{ display: 'flex', flexDirection: 'column', gap: 8, textAlign: 'center' }}
>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Enter this code on GitHub:</div>
<div className="flex items-center justify-center gap-2">
<div
style={{ fontSize: 24, fontWeight: 700, letterSpacing: 4, color: 'var(--foreground)', fontFamily: 'monospace' }}
data-testid="github-user-code"
>
{userCode}
</div>
<button
className="border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={handleCopyCode}
title="Copy code"
data-testid="github-copy-code"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
{verificationUri && (
<button
className="border-none bg-transparent text-muted-foreground cursor-pointer hover:text-foreground underline"
style={{ fontSize: 12 }}
onClick={handleOpenUrl}
data-testid="github-open-url"
>
{verificationUri}
</button>
)}
<div className="flex items-center justify-center gap-2" style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>
<CircleNotch size={14} className="animate-spin" />
Waiting for authorization...
</div>
</div>
<button
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground"
style={{ fontSize: 12, padding: '6px 12px', alignSelf: 'center' }}
onClick={onCancel}
data-testid="github-cancel"
>
Cancel
</button>
</div>
)
}
function LoginButton({ onLogin, disabled, errorMessage, onRetry }: { onLogin: () => void; disabled: boolean; errorMessage: string | null; onRetry?: () => void }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
className="border-none rounded cursor-pointer flex items-center justify-center gap-2"
style={{ fontSize: 13, fontWeight: 500, padding: '8px 16px', background: 'var(--foreground)', color: 'var(--background)', height: 36 }}
onClick={onLogin}
disabled={disabled}
data-testid="github-login"
>
<GithubLogo size={16} weight="fill" />
Login with GitHub
</button>
{errorMessage && (
<div className="flex items-center gap-2" style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)' }}>
<span data-testid="github-error">{errorMessage}</span>
{onRetry && (
<button
className="border-none bg-transparent cursor-pointer hover:text-foreground flex items-center gap-1"
style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)', padding: 0 }}
onClick={onRetry}
data-testid="github-retry"
>
<ArrowClockwise size={12} />
Retry
</button>
)}
</div>
)}
</div>
)
}

View File

@@ -7,6 +7,9 @@ vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
vi.mock('../utils/url', () => ({
openExternalUrl: vi.fn().mockResolvedValue(undefined),
}))
import { mockInvoke } from '../mock-tauri'
const mockInvokeFn = vi.mocked(mockInvoke)
@@ -20,6 +23,7 @@ describe('GitHubVaultModal', () => {
const onClose = vi.fn()
const onVaultCloned = vi.fn()
const onOpenSettings = vi.fn()
const onGitHubConnected = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
@@ -38,7 +42,7 @@ describe('GitHubVaultModal', () => {
expect(container.querySelector('[data-testid="github-vault-modal"]')).not.toBeInTheDocument()
})
it('shows connect prompt when no GitHub token', () => {
it('shows settings fallback when no token and no onGitHubConnected', () => {
render(
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />
)
@@ -54,6 +58,37 @@ describe('GitHubVaultModal', () => {
expect(onOpenSettings).toHaveBeenCalled()
})
it('shows inline device flow when no token and onGitHubConnected is provided', () => {
render(
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
)
expect(screen.getByTestId('github-login')).toBeInTheDocument()
expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
expect(screen.queryByTestId('github-open-settings')).not.toBeInTheDocument()
})
it('shows device code after starting inline OAuth flow', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'github_device_flow_start') {
return { device_code: 'dc_modal', user_code: 'MODAL-5678', verification_uri: 'https://github.com/login/device', expires_in: 900, interval: 5 }
}
if (cmd === 'github_device_flow_poll') {
return { status: 'pending', access_token: null, error: 'authorization_pending' }
}
return null
})
render(
<GitHubVaultModal open={true} githubToken={null} onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} onGitHubConnected={onGitHubConnected} />
)
fireEvent.click(screen.getByTestId('github-login'))
await waitFor(() => {
expect(screen.getByTestId('github-user-code')).toHaveTextContent('MODAL-5678')
})
})
it('shows clone and create tabs when token is present', async () => {
render(
<GitHubVaultModal open={true} githubToken="gho_test" onClose={onClose} onVaultCloned={onVaultCloned} onOpenSettings={onOpenSettings} />

View File

@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
import type { GithubRepo } from '../types'
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
@@ -18,6 +19,7 @@ interface GitHubVaultModalProps {
onClose: () => void
onVaultCloned: (path: string, label: string) => void
onOpenSettings: () => void
onGitHubConnected?: (token: string, username: string) => void
}
type CloneStatus = 'idle' | 'cloning' | 'success' | 'error'
@@ -244,7 +246,7 @@ function CloningProgress({ repoName }: { repoName: string }) {
)
}
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings }: GitHubVaultModalProps) {
export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, onOpenSettings, onGitHubConnected }: GitHubVaultModalProps) {
const [tab, setTab] = useState('clone')
const [repos, setRepos] = useState<GithubRepo[]>([])
const [loading, setLoading] = useState(false)
@@ -365,15 +367,21 @@ export function GitHubVaultModal({ open, githubToken, onClose, onVaultCloned, on
<DialogDescription>Connect your GitHub account to clone or create vaults backed by GitHub repos.</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-6">
<p className="text-sm text-muted-foreground text-center">
You need to connect your GitHub account first. Add your GitHub token in Settings.
</p>
<Button
onClick={() => { handleClose(); onOpenSettings() }}
data-testid="github-open-settings"
>
Open Settings
</Button>
{onGitHubConnected ? (
<GitHubDeviceFlow onConnected={onGitHubConnected} />
) : (
<>
<p className="text-sm text-muted-foreground text-center">
You need to connect your GitHub account first. Add your GitHub token in Settings.
</p>
<Button
onClick={() => { handleClose(); onOpenSettings() }}
data-testid="github-open-settings"
>
Open Settings
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -485,25 +485,8 @@ Status: Active
/>
)
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
expect(screen.getByText(/via Related to/)).toBeInTheDocument()
})
it('shows count badge for referenced-by entries', () => {
render(
<Inspector
{...defaultProps}
entry={targetEntry}
content={targetContent}
entries={[targetEntry, essayEntry, procedureEntry]}
/>
)
// 2 entries reference via Belongs to — badge appears in the Referenced by header
const allTwos = screen.getAllByText('2')
expect(allTwos.length).toBeGreaterThanOrEqual(1)
// At least one "2" is inside a badge (span with ml-1 class)
expect(allTwos.some(el => el.classList.contains('ml-1'))).toBe(true)
expect(screen.getByText(/ Belongs to/i)).toBeInTheDocument()
expect(screen.getByText(/ Related to/i)).toBeInTheDocument()
})
it('hides referenced-by section when no entries reference the current note', () => {
@@ -580,7 +563,7 @@ Status: Active
/>
)
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
expect(screen.getByText(/via Topics/)).toBeInTheDocument()
expect(screen.getByText(/ Topics/i)).toBeInTheDocument()
})
it('excludes entries from backlinks when already shown in referenced-by', () => {
@@ -619,7 +602,7 @@ Status: Active
/>
)
// noteA shows in Referenced By (via Belongs to)
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
expect(screen.getByText(/ Belongs to/i)).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
// But NOT in Backlinks (even though outgoingLinks matches) — section hidden
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()

View File

@@ -17,6 +17,7 @@ interface InspectorProps {
entry: VaultEntry | null
content: string | null
entries: VaultEntry[]
allContent?: Record<string, string>
gitHistory: GitCommit[]
onNavigate: (target: string) => void
onViewCommitDiff?: (commitHash: string) => void
@@ -79,7 +80,7 @@ function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): Refer
function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
const { onMouseDown } = useDragRegion()
return (
<div className="flex shrink-0 items-center border-b border-border" style={{ height: 52, padding: '0 12px', gap: 8, cursor: 'default' }} onMouseDown={onMouseDown}>
<div className="flex shrink-0 items-center border-b border-border" style={{ height: 45, padding: '6px 12px', gap: 8, cursor: 'default' }} onMouseDown={onMouseDown}>
{collapsed ? (
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle}>
<SlidersHorizontal size={16} />
@@ -104,7 +105,7 @@ function EmptyInspector() {
}
export function Inspector({
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate,
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
}: InspectorProps) {
const referencedBy = useReferencedBy(entry, entries)
@@ -137,7 +138,7 @@ export function Inspector({
<>
<DynamicPropertiesPanel
entry={entry} content={content} frontmatter={frontmatter}
entries={entries}
entries={entries} allContent={allContent}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}

View File

@@ -77,7 +77,7 @@ describe('DynamicRelationshipsPanel', () => {
onNavigate={onNavigate}
/>
)
const chip = container.querySelector('.group\\/link button')
const chip = container.querySelector('.group\\/link')
expect(chip).toBeTruthy()
expect(chip!.style.color).toBe(expectedColor)
})
@@ -467,18 +467,8 @@ describe('ReferencedByPanel', () => {
expect(screen.getByText('Write Essays')).toBeInTheDocument()
expect(screen.getByText('On Writing Well')).toBeInTheDocument()
expect(screen.getByText('SEO Experiment')).toBeInTheDocument()
expect(screen.getByText(/via Belongs to/)).toBeInTheDocument()
expect(screen.getByText(/via Related to/)).toBeInTheDocument()
})
it('shows count badge when items exist', () => {
const items: ReferencedByItem[] = [
{ entry: makeEntry({ path: '/vault/a.md', title: 'A' }), viaKey: 'Has' },
{ entry: makeEntry({ path: '/vault/b.md', title: 'B' }), viaKey: 'Has' },
{ entry: makeEntry({ path: '/vault/c.md', title: 'C' }), viaKey: 'Topics' },
]
render(<ReferencedByPanel typeEntryMap={{}} items={items} onNavigate={onNavigate} />)
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText(/ Belongs to/i)).toBeInTheDocument()
expect(screen.getByText(/ Related to/i)).toBeInTheDocument()
})
it('navigates when clicking a referenced-by entry', () => {

View File

@@ -2,7 +2,7 @@ import { useMemo, useCallback, useState, useRef } from 'react'
import type { ComponentType, SVGAttributes } from 'react'
import { wikilinkTarget, wikilinkDisplay } from '../utils/wikilink'
import type { VaultEntry, GitCommit } from '../types'
import { Trash, X, Plus } from '@phosphor-icons/react'
import { Trash, X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
@@ -48,35 +48,36 @@ function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick,
const isDimmed = isArchived || isTrashed
const color = isDimmed ? 'var(--muted-foreground)' : typeColor
return (
<div className="group/link flex items-center gap-1">
<button
className="flex flex-1 items-center justify-between gap-2 border-none text-left cursor-pointer hover:opacity-80 min-w-0"
style={{
background: isDimmed ? 'var(--muted)' : (bgColor ?? 'transparent'),
color, borderRadius: 6, padding: bgColor ? '6px 10px' : '4px 0',
fontSize: 12, fontWeight: 500, opacity: isDimmed ? 0.7 : 1,
}}
onClick={onClick}
title={title}
>
<span className="flex items-center gap-1 flex-1 truncate">
{isTrashed && <Trash size={12} className="shrink-0" />}
{label}
<StatusSuffix isArchived={isArchived} isTrashed={isTrashed} />
</span>
<TypeIcon width={14} height={14} className="shrink-0" style={{ color }} />
</button>
{onRemove && (
<button
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover/link:opacity-100"
onClick={onRemove}
title="Remove from relation"
data-testid="remove-relation-ref"
>
<X size={12} />
</button>
)}
</div>
<button
className={`group/link flex w-full items-center justify-between gap-2 border-none text-left cursor-pointer min-w-0${bgColor ? ' ring-inset hover:ring-1 hover:ring-current' : ' hover:opacity-80'}`}
style={{
background: isDimmed ? 'var(--muted)' : (bgColor ?? 'transparent'),
color, borderRadius: 6, padding: bgColor ? '6px 10px' : '4px 0',
fontSize: 12, fontWeight: 500, opacity: isDimmed ? 0.7 : 1,
}}
onClick={onClick}
title={title}
>
<span className="flex items-center gap-1 flex-1 truncate">
{isTrashed && <Trash size={12} className="shrink-0" />}
{label}
<StatusSuffix isArchived={isArchived} isTrashed={isTrashed} />
</span>
<span className="flex items-center gap-1.5 shrink-0">
{onRemove && (
<span
className="flex items-center opacity-0 transition-opacity group-hover/link:opacity-100"
onClick={(e) => { e.stopPropagation(); onRemove() }}
role="button"
title="Remove from relation"
data-testid="remove-relation-ref"
>
<X size={14} />
</span>
)}
<TypeIcon width={14} height={14} className="shrink-0" style={{ color, opacity: 0.5 }} />
</span>
</button>
)
}
@@ -149,25 +150,24 @@ function InlineAddNote({ entries, onAdd }: {
if (!active) {
return (
<button
className="mt-1 flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground"
style={{ fontSize: 11 }}
className="mt-1 w-full border border-dashed border-border bg-transparent text-left text-muted-foreground cursor-pointer hover:border-foreground hover:text-foreground"
style={{ borderRadius: 6, padding: '6px 10px', fontSize: 12 }}
onClick={() => setActive(true)}
data-testid="add-relation-ref"
>
<Plus size={10} />
<span>Add</span>
Add
</button>
)
}
return (
<div className="relative mt-1">
<div className="flex items-center gap-1">
<div className="group/add relative flex items-center">
<input
ref={inputRef}
autoFocus
className="flex-1 border border-border bg-transparent px-2 py-0.5 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none', minWidth: 0 }}
className="w-full border border-border bg-transparent text-foreground"
style={{ borderRadius: 6, outline: 'none', minWidth: 0, padding: '6px 10px', fontSize: 12 }}
placeholder="Note title"
value={query}
onChange={e => setQuery(e.target.value)}
@@ -175,14 +175,7 @@ function InlineAddNote({ entries, onAdd }: {
data-testid="add-relation-ref-input"
/>
<button
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
onClick={handleConfirm}
disabled={!query.trim()}
>
<Plus size={12} />
</button>
<button
className="shrink-0 border-none bg-transparent p-0.5 text-muted-foreground hover:text-foreground"
className="absolute right-1 top-1/2 -translate-y-1/2 border-none bg-transparent p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/add:opacity-100"
onClick={() => { setQuery(''); setActive(false) }}
>
<X size={12} />
@@ -421,15 +414,12 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
if (items.length === 0) return null
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">
Referenced by <span className="ml-1" style={{ fontWeight: 400 }}>{items.length}</span>
</h4>
<div className="referenced-by-panel">
<div className="flex flex-col gap-2.5">
{grouped.map(([viaKey, groupEntries]) => (
<div key={viaKey}>
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
via {viaKey}
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
{viaKey}
</span>
<div className="flex flex-col gap-0.5">
{groupEntries.map((e) => {

View File

@@ -37,7 +37,7 @@ function matchEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultE
return matches.slice(0, MAX_RESULTS).map(e => {
const isA = e.isA
const te = typeEntryMap[isA ?? '']
const noteType = isA && isA !== 'Note' ? isA : undefined
const noteType = isA || undefined
return {
title: e.title,
noteType,

View File

@@ -288,27 +288,27 @@ describe('NoteList click behavior', () => {
expect(noopSelect).not.toHaveBeenCalled()
})
it('Cmd+Click toggles multi-select on a note', () => {
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()} />)
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
expect(noopReplace).not.toHaveBeenCalled()
expect(screen.getByTestId('multi-selected-item')).toBeInTheDocument()
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
})
it('Ctrl+Click toggles multi-select (Windows/Linux)', () => {
it('Ctrl+Click calls onSelectNote (Windows/Linux)', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
expect(noopReplace).not.toHaveBeenCalled()
expect(screen.getByTestId('multi-selected-item')).toBeInTheDocument()
})
it('Cmd+Click on entity pinned card toggles multi-select', () => {
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()} />
)
const titles = screen.getAllByText('Build Laputa App')
fireEvent.click(titles[titles.length - 1], { metaKey: true })
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
expect(noopReplace).not.toHaveBeenCalled()
})
@@ -996,40 +996,10 @@ describe('NoteList — multi-select', () => {
noopReplace.mockClear()
})
it('Cmd+Click selects multiple notes', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
const selected = screen.getAllByTestId('multi-selected-item')
expect(selected).toHaveLength(2)
})
it('Cmd+Click toggles off an already-selected note', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
expect(screen.getAllByTestId('multi-selected-item')).toHaveLength(1)
// Toggle off
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
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()} />)
// Multi-select two notes
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
expect(screen.getAllByTestId('multi-selected-item')).toHaveLength(2)
// Regular click clears selection and opens note
fireEvent.click(screen.getByText('Matteo Cellini'))
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
expect(noopReplace).toHaveBeenCalledWith(mockEntries[2])
})
it('Shift+Click selects a range of notes', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
// First Cmd+Click to set anchor
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
// Regular click to set anchor
fireEvent.click(screen.getByText('Build Laputa App'))
// Shift+Click to select range
fireEvent.click(screen.getByText('Matteo Cellini'), { shiftKey: true })
// Should select all notes between Build Laputa App and Matteo Cellini (inclusive)
@@ -1037,10 +1007,34 @@ describe('NoteList — multi-select', () => {
expect(selected.length).toBeGreaterThanOrEqual(2)
})
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()} />)
// Select range via Shift+click
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(1)
// Regular click clears selection and opens note
fireEvent.click(screen.getByText('Matteo Cellini'))
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
expect(noopReplace).toHaveBeenCalledWith(mockEntries[2])
})
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()} />)
// Select range via Shift+click
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getAllByTestId('multi-selected-item').length).toBeGreaterThanOrEqual(1)
// Cmd+click clears selection and opens in new tab
fireEvent.click(screen.getByText('Matteo Cellini'), { metaKey: true })
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
expect(noopSelect).toHaveBeenCalledWith(mockEntries[2])
})
it('shows bulk action bar with correct count', () => {
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { metaKey: true })
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
expect(screen.getByText('2 selected')).toBeInTheDocument()
})
@@ -1048,32 +1042,159 @@ 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} />)
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByTestId('bulk-archive-btn'))
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path])
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
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} />)
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByTestId('bulk-trash-btn'))
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path])
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
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()} />)
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('bulk-clear-btn'))
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
})
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} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'e', metaKey: true })
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
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} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Backspace', metaKey: true })
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
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} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.keyDown(window, { key: 'Delete', metaKey: true })
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
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()} />)
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
})
// --- Type note filtering tests ---
const typeEntry: VaultEntry = {
path: '/Users/luca/Laputa/types/project.md',
filename: 'project.md',
title: 'Project',
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: 'Defines the Project type.',
wordCount: 50,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
}
const entriesWithType = [...mockEntries, typeEntry]
describe('NoteList — type note filtering', () => {
beforeEach(() => {
noopSelect.mockClear()
noopReplace.mockClear()
})
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()} />
)
// The type note snippet should NOT be visible (PinnedCard was removed)
expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument()
// But the Project instance should still be in the list
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
})
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()} />
)
const headerLink = screen.getByTestId('type-header-link')
expect(headerLink).toBeInTheDocument()
expect(headerLink.textContent).toBe('Project')
expect(headerLink.style.cursor).toBe('pointer')
fireEvent.click(headerLink)
expect(noopReplace).toHaveBeenCalledWith(typeEntry)
})
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()} />
)
expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument()
})
})
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()} />
)
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('80px')
})
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()} />
)
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')
})
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()} />
)
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
expect(header.style.paddingLeft).toBe('')
})
})

View File

@@ -26,6 +26,7 @@ interface NoteListProps {
allContent: Record<string, string>
modifiedFiles?: ModifiedFile[]
getNoteStatus?: (path: string) => NoteStatus
sidebarCollapsed?: boolean
onSelectNote: (entry: VaultEntry) => void
onReplaceActiveTab: (entry: VaultEntry) => void
onCreateNote: () => void
@@ -135,31 +136,24 @@ function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggl
)
}
function ListViewHeader({ typeDocument, isTrashView, expiredTrashCount, typeEntryMap, onClickNote }: {
typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
function ListViewHeader({ isTrashView, expiredTrashCount }: {
isTrashView: boolean; expiredTrashCount: number
}) {
return (
<>
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
</>
)
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function ListView({ typeDocument, isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onClickNote }: {
typeDocument: VaultEntry | null; isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem }: {
isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
const emptyText = isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const hasHeader = typeDocument || (isTrashView && expiredTrashCount > 0)
const hasHeader = isTrashView && expiredTrashCount > 0
if (searched.length === 0) {
return (
<div className="h-full overflow-y-auto">
{hasHeader && <ListViewHeader typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
{hasHeader && <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} />}
<EmptyMessage text={emptyText} />
</div>
)
@@ -171,7 +165,7 @@ function ListView({ typeDocument, isTrashView, isChangesView, expiredTrashCount,
data={searched}
overscan={200}
components={{
Header: hasHeader ? () => <ListViewHeader typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} typeEntryMap={typeEntryMap} onClickNote={onClickNote} /> : undefined,
Header: hasHeader ? () => <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} /> : undefined,
}}
itemContent={(_index, entry) => renderItem(entry)}
/>
@@ -196,16 +190,17 @@ function countExpiredTrash(entries: VaultEntry[]): number {
// --- Click routing ---
type MultiSelectActions = { toggle: (path: string) => void; selectRange: (path: string) => void; clear: () => void }
type MultiSelectActions = { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
function routeNoteClick(
entry: VaultEntry, e: React.MouseEvent,
onReplaceActiveTab: (entry: VaultEntry) => void,
onSelectNote: (entry: VaultEntry) => void,
multiSelect: MultiSelectActions,
) {
if (e.shiftKey) { multiSelect.selectRange(entry.path) }
else if (e.metaKey || e.ctrlKey) { multiSelect.toggle(entry.path) }
else { multiSelect.clear(); onReplaceActiveTab(entry) }
else if (e.metaKey || e.ctrlKey) { multiSelect.clear(); onSelectNote(entry) }
else { multiSelect.clear(); multiSelect.setAnchor(entry.path); onReplaceActiveTab(entry) }
}
// --- Pure helpers extracted from NoteListInner to reduce cyclomatic complexity ---
@@ -275,7 +270,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
const defaultGetNoteStatus = (): NoteStatus => 'clean'
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -307,16 +302,29 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const listDirection = listConfig.direction
const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet })
const multiSelect = useMultiSelect(searched)
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
// Clear multi-select when sidebar selection changes
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
routeNoteClick(entry, e, onReplaceActiveTab, multiSelect)
}, [onReplaceActiveTab, multiSelect])
routeNoteClick(entry, e, onReplaceActiveTab, onSelectNote, multiSelect)
}, [onReplaceActiveTab, onSelectNote, multiSelect])
// Keyboard: Escape to clear, Cmd+A to select all
const handleBulkArchive = useCallback(() => {
const paths = [...multiSelect.selectedPaths]
multiSelect.clear()
onBulkArchive?.(paths)
}, [multiSelect, onBulkArchive])
const handleBulkTrash = useCallback(() => {
const paths = [...multiSelect.selectedPaths]
multiSelect.clear()
onBulkTrash?.(paths)
}, [multiSelect, onBulkTrash])
// Keyboard: Escape to clear, Cmd+A to select all, Cmd+E/Cmd+Delete for bulk actions
// Uses capture phase so bulk shortcuts preempt the global single-note handler
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && multiSelect.isMultiSelecting) {
@@ -331,31 +339,37 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
multiSelect.selectAll()
}
}
if (multiSelect.isMultiSelecting && (e.metaKey || e.ctrlKey)) {
if (e.key === 'e') {
e.preventDefault()
e.stopPropagation()
handleBulkArchive()
} else if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
e.stopPropagation()
handleBulkTrash()
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [multiSelect, isEntityView])
const handleBulkArchive = useCallback(() => {
const paths = [...multiSelect.selectedPaths]
multiSelect.clear()
onBulkArchive?.(paths)
}, [multiSelect, onBulkArchive])
const handleBulkTrash = useCallback(() => {
const paths = [...multiSelect.selectedPaths]
multiSelect.clear()
onBulkTrash?.(paths)
}, [multiSelect, onBulkTrash])
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [multiSelect, isEntityView, handleBulkArchive, handleBulkTrash])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths])
return (
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" onMouseDown={onDragMouseDown} style={{ cursor: 'default' }}>
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">{resolveHeaderTitle(selection, typeDocument)}</h3>
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" onMouseDown={onDragMouseDown} style={{ cursor: 'default', paddingLeft: sidebarCollapsed ? 80 : undefined }}>
<h3
className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold"
style={typeDocument ? { cursor: 'pointer' } : undefined}
onClick={typeDocument ? () => onReplaceActiveTab(typeDocument) : undefined}
data-testid={typeDocument ? 'type-header-link' : undefined}
>
{resolveHeaderTitle(selection, typeDocument)}
</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} onChange={handleSortChange} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
@@ -377,7 +391,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
{isEntityView && selection.kind === 'entity' ? (
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView typeDocument={typeDocument} isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
<ListView isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} />
)}
</div>

View File

@@ -12,6 +12,8 @@ vi.mock('../mock-tauri', () => ({
import { mockInvoke } from '../mock-tauri'
const mockInvokeFn = vi.mocked(mockInvoke)
const NOW = Math.floor(Date.now() / 1000)
const MOCK_ENTRIES: VaultEntry[] = [
{
path: '/vault/essay/ai-apis.md',
@@ -27,16 +29,16 @@ const MOCK_ENTRIES: VaultEntry[] = [
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000,
createdAt: Date.now() / 1000,
modifiedAt: NOW - 7200,
createdAt: NOW - 86400 * 30,
fileSize: 500,
snippet: 'A guide to designing APIs for AI',
wordCount: 0,
wordCount: 1247,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
},
{
path: '/vault/event/retreat.md',
@@ -52,16 +54,16 @@ const MOCK_ENTRIES: VaultEntry[] = [
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: Date.now() / 1000,
createdAt: Date.now() / 1000,
modifiedAt: NOW - 86400 * 5,
createdAt: NOW - 86400 * 5,
fileSize: 300,
snippet: 'Team retreat event',
wordCount: 0,
wordCount: 856,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
outgoingLinks: ['person/bob'],
},
]
@@ -195,7 +197,7 @@ describe('SearchPanel', () => {
expect(screen.getByText('Result One')).toBeInTheDocument()
})
fireEvent.keyDown(window, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'ArrowDown' })
await waitFor(() => {
const resultTwo = screen.getByText('Result Two').closest('[class*="cursor-pointer"]')!
@@ -270,6 +272,49 @@ describe('SearchPanel', () => {
})
})
it('shows metadata subtitle with word count and links', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'Content', score: 0.9, note_type: null },
],
elapsed_ms: 20,
})
render(
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
)
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
await waitFor(() => {
expect(screen.getByText(/1,247 words/)).toBeInTheDocument()
expect(screen.getByText(/3 links/)).toBeInTheDocument()
})
})
it('omits links from subtitle when entry has zero outgoing links', async () => {
const noLinksEntries = MOCK_ENTRIES.map(e =>
e.path === '/vault/essay/ai-apis.md' ? { ...e, outgoingLinks: [] } : e,
)
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null },
],
elapsed_ms: 20,
})
render(
<SearchPanel open={true} vaultPath="/vault" entries={noLinksEntries} onSelectNote={vi.fn()} onClose={vi.fn()} />,
)
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } })
await waitFor(() => {
expect(screen.getByText(/1,247 words/)).toBeInTheDocument()
expect(screen.queryByText(/links/)).not.toBeInTheDocument()
})
})
it('shows loading spinner while searching', async () => {
const resolvers: ((v: unknown) => void)[] = []
mockInvokeFn.mockImplementation(

View File

@@ -1,10 +1,11 @@
import { useRef, useEffect, useCallback } from 'react'
import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import type { SearchResult, VaultEntry } from '../types'
import { useUnifiedSearch } from '../hooks/useUnifiedSearch'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { getTypeColor, buildTypeEntryMap } from '../utils/typeColors'
import { formatSearchSubtitle } from '../utils/noteListHelpers'
import { getTypeIcon } from './NoteItem'
interface SearchPanelProps {
open: boolean
@@ -63,8 +64,8 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }:
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const entryLookup = useMemo(() => {
const map = new Map<string, { isA: string | null }>()
for (const e of entries) map.set(e.path, { isA: e.isA })
const map = new Map<string, VaultEntry>()
for (const e of entries) map.set(e.path, e)
return map
}, [entries])
@@ -148,7 +149,7 @@ interface SearchContentProps {
selectedIndex: number
loading: boolean
elapsedMs: number | null
entryLookup: Map<string, { isA: string | null }>
entryLookup: Map<string, VaultEntry>
typeEntryMap: Record<string, VaultEntry>
listRef: React.RefObject<HTMLDivElement | null>
onSelect: (result: SearchResult) => void
@@ -190,10 +191,13 @@ function SearchContent({
</div>
<div ref={listRef}>
{results.map((result, i) => {
const isA = entryLookup.get(result.path)?.isA ?? result.noteType
const noteType = isA && isA !== 'Note' ? isA : null
const typeColor = noteType ? getTypeColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
const typeLightColor = noteType ? getTypeLightColor(isA, typeEntryMap[isA ?? '']?.color) : undefined
const entry = entryLookup.get(result.path)
const isA = entry?.isA ?? result.noteType
const noteType = isA || null
const te = typeEntryMap[isA ?? '']
const typeColor = noteType ? getTypeColor(isA, te?.color) : undefined
const TypeIcon = getTypeIcon(isA ?? null, te?.icon)
const subtitle = entry ? formatSearchSubtitle(entry) : null
return (
<div
key={result.path}
@@ -205,16 +209,16 @@ function SearchContent({
onMouseEnter={() => onHover(i)}
>
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-foreground">{result.title}</span>
{/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */}
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{result.title}</span>
{noteType && (
<Badge variant="secondary" className="text-[10px]" style={typeColor ? { color: typeColor, backgroundColor: typeLightColor } : undefined}>
{noteType}
</Badge>
<span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span>
)}
</div>
{result.snippet && (
<p className="mt-1 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground">
{result.snippet}
{subtitle && (
<p className="mt-0.5 pl-[22px] text-[11px] text-muted-foreground">
{subtitle}
</p>
)}
</div>

View File

@@ -5,6 +5,7 @@ import type { Settings } from '../types'
// Mock the tauri/mock-tauri calls used by GitHubSection
const mockInvokeFn = vi.fn()
const mockOpenExternalUrl = vi.fn().mockResolvedValue(undefined)
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
@@ -12,6 +13,9 @@ vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args),
}))
const emptySettings: Settings = {
anthropic_key: null,
@@ -272,9 +276,6 @@ describe('SettingsPanel', () => {
return null
})
// Mock window.open
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
@@ -286,8 +287,57 @@ describe('SettingsPanel', () => {
expect(screen.getByTestId('github-user-code')).toHaveTextContent('TEST-1234')
})
expect(windowOpen).toHaveBeenCalledWith('https://github.com/login/device', '_blank')
windowOpen.mockRestore()
expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://github.com/login/device')
})
it('shows verification URL as clickable link in waiting state', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'github_device_flow_start') {
return {
device_code: 'test_device_code',
user_code: 'TEST-1234',
verification_uri: 'https://github.com/login/device',
expires_in: 900,
interval: 5,
}
}
if (cmd === 'github_device_flow_poll') {
return { status: 'pending', access_token: null, error: 'authorization_pending' }
}
return null
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('github-login'))
await waitFor(() => {
const urlButton = screen.getByTestId('github-open-url')
expect(urlButton).toBeInTheDocument()
expect(urlButton).toHaveTextContent('https://github.com/login/device')
})
})
it('shows retry button when OAuth flow errors', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'github_device_flow_start') {
throw 'Network error'
}
return null
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('github-login'))
await waitFor(() => {
expect(screen.getByTestId('github-error')).toHaveTextContent('Network error')
expect(screen.getByTestId('github-retry')).toBeInTheDocument()
})
})
it('shows GitHub section description about connecting', () => {

View File

@@ -1,12 +1,7 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut, CircleNotch } from '@phosphor-icons/react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types'
function tauriCall<T>(cmd: string, args: Record<string, unknown> = {}): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
}
import { useState, useRef, useCallback } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
import type { Settings } from '../types'
interface SettingsPanelProps {
open: boolean
@@ -73,8 +68,6 @@ function KeyField({ label, placeholder, value, onChange, onClear }: KeyFieldProp
// --- GitHub OAuth Section ---
type OAuthStatus = 'idle' | 'waiting' | 'error'
interface GitHubSectionProps {
githubUsername: string | null
githubToken: string | null
@@ -82,120 +75,14 @@ interface GitHubSectionProps {
onDisconnect: () => void
}
/** Process a device flow poll result. Returns 'done' if auth complete, 'continue' to keep polling. */
function processPollResult(
result: DeviceFlowPollResult,
callbacks: {
onComplete: (token: string) => Promise<void>
onExpired: () => void
onError: (msg: string) => void
},
): 'done' | 'continue' {
if (result.status === 'complete' && result.access_token) {
callbacks.onComplete(result.access_token)
return 'done'
}
if (result.status === 'expired') {
callbacks.onExpired()
return 'done'
}
if (result.status === 'error') {
callbacks.onError(result.error ?? 'Authorization failed.')
return 'done'
}
return 'continue' // status === 'pending'
}
function GitHubSection({ githubUsername, githubToken, onConnected, onDisconnect }: GitHubSectionProps) {
const [oauthStatus, setOauthStatus] = useState<OAuthStatus>('idle')
const [userCode, setUserCode] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const pollingRef = useRef(false)
const deviceCodeRef = useRef<string | null>(null)
const isConnected = !!githubToken && !!githubUsername
const stopPolling = useCallback(() => {
pollingRef.current = false
deviceCodeRef.current = null
}, [])
useEffect(() => {
return () => { pollingRef.current = false }
}, [])
const handleLogin = useCallback(async () => {
setOauthStatus('waiting')
setErrorMessage(null)
setUserCode(null)
try {
const flowStart = await tauriCall<DeviceFlowStart>('github_device_flow_start')
setUserCode(flowStart.user_code)
deviceCodeRef.current = flowStart.device_code
window.open(flowStart.verification_uri, '_blank')
pollingRef.current = true
const intervalMs = Math.max(flowStart.interval * 1000, 5000)
const pollLoop = async () => {
while (pollingRef.current && deviceCodeRef.current) {
await new Promise(r => setTimeout(r, intervalMs))
if (!pollingRef.current) break
const result = await tauriCall<DeviceFlowPollResult>('github_device_flow_poll', {
deviceCode: deviceCodeRef.current,
})
const outcome = processPollResult(result, {
onComplete: async (token) => {
const user = await tauriCall<GitHubUser>('github_get_user', { token })
stopPolling()
setOauthStatus('idle')
setUserCode(null)
onConnected(token, user.login)
},
onExpired: () => {
stopPolling()
setOauthStatus('error')
setErrorMessage('Authorization expired. Please try again.')
},
onError: (msg) => {
stopPolling()
setOauthStatus('error')
setErrorMessage(msg)
},
})
if (outcome === 'done') return
}
}
pollLoop().catch(err => {
stopPolling()
setOauthStatus('error')
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Polling failed.')
})
} catch (err) {
setOauthStatus('error')
setErrorMessage(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Failed to start login.')
}
}, [onConnected, stopPolling])
const resetOAuth = useCallback(() => {
stopPolling()
setOauthStatus('idle')
setUserCode(null)
setErrorMessage(null)
}, [stopPolling])
if (isConnected && oauthStatus === 'idle') {
return <GitHubConnectedRow username={githubUsername!} onDisconnect={() => { resetOAuth(); onDisconnect() }} />
if (isConnected) {
return <GitHubConnectedRow username={githubUsername!} onDisconnect={onDisconnect} />
}
if (oauthStatus === 'waiting' && userCode) {
return <GitHubWaitingView userCode={userCode} onCancel={resetOAuth} />
}
return <GitHubLoginButton onLogin={handleLogin} disabled={oauthStatus === 'waiting'} errorMessage={errorMessage} />
return <GitHubDeviceFlow onConnected={onConnected} />
}
function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDisconnect: () => void }) {
@@ -224,59 +111,6 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi
)
}
function GitHubWaitingView({ userCode, onCancel }: { userCode: string; onCancel: () => void }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} data-testid="github-waiting">
<div
className="border border-border rounded px-4 py-3"
style={{ display: 'flex', flexDirection: 'column', gap: 8, textAlign: 'center' }}
>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>Enter this code on GitHub:</div>
<div
style={{ fontSize: 24, fontWeight: 700, letterSpacing: 4, color: 'var(--foreground)', fontFamily: 'monospace' }}
data-testid="github-user-code"
>
{userCode}
</div>
<div className="flex items-center justify-center gap-2" style={{ fontSize: 12, color: 'var(--muted-foreground)' }}>
<CircleNotch size={14} className="animate-spin" />
Waiting for authorization...
</div>
</div>
<button
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground"
style={{ fontSize: 12, padding: '6px 12px', alignSelf: 'center' }}
onClick={onCancel}
data-testid="github-cancel"
>
Cancel
</button>
</div>
)
}
function GitHubLoginButton({ onLogin, disabled, errorMessage }: { onLogin: () => void; disabled: boolean; errorMessage: string | null }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button
className="border-none rounded cursor-pointer flex items-center justify-center gap-2"
style={{ fontSize: 13, fontWeight: 500, padding: '8px 16px', background: 'var(--foreground)', color: 'var(--background)', height: 36 }}
onClick={onLogin}
disabled={disabled}
data-testid="github-login"
>
<GithubLogo size={16} weight="fill" />
Login with GitHub
</button>
{errorMessage && (
<div style={{ fontSize: 12, color: 'var(--destructive, #e03e3e)' }} data-testid="github-error">
{errorMessage}
</div>
)}
</div>
)
}
// --- Settings Panel ---
export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) {

View File

@@ -227,7 +227,7 @@ describe('Sidebar', () => {
expect(screen.getByText('Favorites')).toBeInTheDocument()
})
it('renders section group headers with new labels', () => {
it('renders section group headers only for types present in entries', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('Experiments')).toBeInTheDocument()
@@ -236,7 +236,8 @@ describe('Sidebar', () => {
expect(screen.getByText('People')).toBeInTheDocument()
expect(screen.getByText('Events')).toBeInTheDocument()
expect(screen.getByText('Topics')).toBeInTheDocument()
expect(screen.getByText('Types')).toBeInTheDocument()
// No entries with isA: 'Type' in mockEntries → Types section absent
expect(screen.queryByText('Types')).not.toBeInTheDocument()
})
it('shows entity names under their section groups after expanding', () => {
@@ -328,7 +329,7 @@ describe('Sidebar', () => {
const onCreateType = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
const createButtons = screen.getAllByTitle(/^New /)
expect(createButtons.length).toBe(8) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics, Types
expect(createButtons.length).toBe(7) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics (no Type entries → no Types section)
})
it('calls onCreateType with correct type when + button is clicked', () => {
@@ -445,14 +446,39 @@ describe('Sidebar', () => {
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
icon: null,
color: null,
order: null,
outgoingLinks: [],
},
{
path: '/vault/book/ddia.md',
filename: 'ddia.md',
title: 'Designing Data-Intensive Applications',
isA: 'Book',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 400,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
},
]
it('renders custom type sections derived from Type entries', () => {
it('renders custom type sections derived from actual entries', () => {
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
expect(screen.getByText('Books')).toBeInTheDocument()
expect(screen.getByText('Recipes')).toBeInTheDocument()
@@ -478,6 +504,36 @@ describe('Sidebar', () => {
expect(onCreateNewType).toHaveBeenCalled()
})
it('does not show section for type with zero active entries', () => {
// Only Type definitions exist for Book, no actual Book instances
const entriesNoBookInstance = entriesWithCustomTypes.filter((e) => !(e.isA === 'Book' && e.title !== 'Book'))
render(<Sidebar entries={entriesNoBookInstance} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('Books')).not.toBeInTheDocument()
// Recipes still has an instance (Pasta Carbonara)
expect(screen.getByText('Recipes')).toBeInTheDocument()
})
it('hides type section when all entries of that type are trashed', () => {
const entriesWithTrashedOnly: VaultEntry[] = [
{
path: '/vault/event/cancelled.md', filename: 'cancelled.md', title: 'Cancelled Event',
isA: 'Event', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: true, trashedAt: 1700000000,
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('Events')).not.toBeInTheDocument()
})
it('shows no sections when entries list is empty', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('Projects')).not.toBeInTheDocument()
expect(screen.queryByText('People')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
})
it('does not show built-in types as custom sections', () => {
const projectTypeEntry: VaultEntry = {
path: '/vault/type/project.md',

View File

@@ -2,6 +2,7 @@ import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { resolveIcon } from '../utils/iconRegistry'
import { buildTypeEntryMap } from '../utils/typeColors'
import { pluralizeType } from '../hooks/useCommandRegistry'
import { TypeCustomizePopover } from './TypeCustomizePopover'
import { useSectionVisibility } from '../hooks/useSectionVisibility'
import {
@@ -48,7 +49,8 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
{ label: 'Types', type: 'Type', Icon: StackSimple },
]
const BUILT_IN_TYPES = new Set(BUILT_IN_SECTION_GROUPS.map((s) => s.type))
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
// --- Hooks ---
@@ -63,19 +65,31 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
}, [ref, isOpen, onClose])
}
function applyOverrides(typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
return BUILT_IN_SECTION_GROUPS.map((sg) => {
const te = typeEntryMap[sg.type]
if (!te?.icon && !te?.color) return sg
return { ...sg, Icon: te?.icon ? resolveIcon(te.icon) : sg.Icon, customColor: te?.color ?? null }
})
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
function collectActiveTypes(entries: VaultEntry[]): Set<string> {
const types = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
}
return types
}
function buildCustomSections(entries: VaultEntry[]): SectionGroup[] {
return entries
.filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title))
.sort((a, b) => a.title.localeCompare(b.title))
.map((e) => ({ label: e.title + 's', type: e.title, Icon: resolveIcon(e.icon), customColor: e.color }))
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
const builtIn = BUILT_IN_TYPE_MAP.get(type)
const typeEntry = typeEntryMap[type]
const customColor = typeEntry?.color ?? null
if (builtIn) {
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
return { ...builtIn, Icon, customColor }
}
return { label: pluralizeType(type), type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
}
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
const activeTypes = collectActiveTypes(entries)
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
}
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
@@ -89,9 +103,8 @@ function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, Vault
function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => boolean) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const allSectionGroups = useMemo(() => {
const built = applyOverrides(typeEntryMap)
const custom = buildCustomSections(entries)
return sortSections([...built, ...custom], typeEntryMap)
const sections = buildDynamicSections(entries, typeEntryMap)
return sortSections(sections, typeEntryMap)
}, [entries, typeEntryMap])
const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible])
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])

View File

@@ -24,6 +24,8 @@ interface StatusBarProps {
conflictCount?: number
lastCommitInfo?: LastCommitInfo | null
onTriggerSync?: () => void
zoomLevel?: number
onZoomReset?: () => void
}
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
@@ -111,6 +113,7 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConn
const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
const SEP_STYLE = { color: 'var(--border)' } as const
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
if (status === 'syncing') return 'Syncing…'
@@ -129,7 +132,30 @@ function syncIconColor(status: SyncStatus): string {
return 'var(--accent-green)'
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync }: StatusBarProps) {
function CommitBadge({ info }: { info: LastCommitInfo }) {
if (info.commitUrl) {
return (
<span
role="button"
onClick={() => openExternalUrl(info.commitUrl!)}
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', textDecoration: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
title={`Open commit ${info.shortHash} on GitHub`}
data-testid="status-commit-link"
onMouseEnter={e => { e.currentTarget.style.color = 'var(--foreground)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--muted-foreground)' }}
>
<GitCommitHorizontal size={13} />{info.shortHash}
</span>
)
}
return (
<span style={ICON_STYLE} data-testid="status-commit-hash">
<GitCommitHorizontal size={13} />{info.shortHash}
</span>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset }: StatusBarProps) {
// Force re-render every 30s to keep relative time label fresh
const [, setTick] = useState(0)
useEffect(() => {
@@ -138,7 +164,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
}, [])
const syncLabel = formatSyncLabel(syncStatus, lastSyncTime)
const SyncIcon = syncStatus === 'syncing' ? Loader2 : syncStatus === 'conflict' ? AlertTriangle : RefreshCw
const SyncIcon = SYNC_ICON_MAP[syncStatus] ?? RefreshCw
return (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
@@ -156,25 +182,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
>
<SyncIcon size={13} style={{ color: syncIconColor(syncStatus) }} className={syncStatus === 'syncing' ? 'animate-spin' : ''} />{syncLabel}
</span>
{lastCommitInfo && (
lastCommitInfo.commitUrl ? (
<span
role="button"
onClick={() => openExternalUrl(lastCommitInfo.commitUrl!)}
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', textDecoration: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
title={`Open commit ${lastCommitInfo.shortHash} on GitHub`}
data-testid="status-commit-link"
onMouseEnter={e => { e.currentTarget.style.color = 'var(--foreground)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--muted-foreground)' }}
>
<GitCommitHorizontal size={13} />{lastCommitInfo.shortHash}
</span>
) : (
<span style={ICON_STYLE} data-testid="status-commit-hash">
<GitCommitHorizontal size={13} />{lastCommitInfo.shortHash}
</span>
)
)}
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
{conflictCount > 0 && (
<>
<span style={SEP_STYLE}>|</span>
@@ -201,6 +209,17 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
{zoomLevel !== 100 && (
<span
role="button"
onClick={onZoomReset}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Reset zoom (⌘0)"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-zoom"
>{zoomLevel}%</span>
)}
<span style={DISABLED_STYLE} title="Coming soon"><Bell size={14} /></span>
<span
role="button"

View File

@@ -1,5 +1,7 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { getStatusStyle, SUGGESTED_STATUSES } from '../utils/statusStyles'
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getStatusStyle, SUGGESTED_STATUSES, setStatusColor, getStatusColorKey } from '../utils/statusStyles'
import { ACCENT_COLORS } from '../utils/typeColors'
export function StatusPill({ status, className }: { status: string; className?: string }) {
const style = getStatusStyle(status)
@@ -25,33 +27,193 @@ export function StatusPill({ status, className }: { status: string; className?:
)
}
function ColorPickerRow({ status, onColorChange }: { status: string; onColorChange: (status: string, colorKey: string) => void }) {
const currentKey = getStatusColorKey(status)
return (
<div className="flex items-center gap-1 px-3 py-1.5" data-testid={`color-picker-${status}`}>
{ACCENT_COLORS.map(c => (
<button
key={c.key}
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0 transition-transform hover:scale-125"
style={{ backgroundColor: c.css }}
onClick={(e) => { e.stopPropagation(); onColorChange(status, c.key) }}
title={c.label}
data-testid={`color-option-${c.key}`}
>
{currentKey === c.key && (
<span style={{ color: 'white', fontSize: 8, lineHeight: 1 }}>{'\u2713'}</span>
)}
</button>
))}
</div>
)
}
function StatusOption({
status,
highlighted,
onSelect,
onMouseEnter,
colorEditing,
onToggleColor,
onColorChange,
}: {
status: string
highlighted: boolean
onSelect: (status: string) => void
onMouseEnter: () => void
colorEditing: boolean
onToggleColor: (status: string) => void
onColorChange: (status: string, colorKey: string) => void
}) {
const style = getStatusStyle(status)
return (
<button
className="flex w-full items-center border-none bg-transparent px-2 py-1 text-left transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
}}
onClick={() => onSelect(status)}
onMouseEnter={onMouseEnter}
data-testid={`status-option-${status}`}
>
<StatusPill status={status} />
</button>
<>
<div
className="flex w-full items-center gap-1 px-2 py-1 transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
}}
onMouseEnter={onMouseEnter}
>
<button
className="flex min-w-0 flex-1 items-center border-none bg-transparent p-0 text-left"
onClick={() => onSelect(status)}
data-testid={`status-option-${status}`}
>
<StatusPill status={status} />
</button>
<button
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0"
style={{ backgroundColor: style.color }}
onClick={() => onToggleColor(status)}
title="Change color"
data-testid={`status-color-swatch-${status}`}
/>
</div>
{colorEditing && <ColorPickerRow status={status} onColorChange={onColorChange} />}
</>
)
}
const SECTION_LABEL_STYLE = {
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="px-2 py-1">
<span className="text-muted-foreground" style={SECTION_LABEL_STYLE}>{children}</span>
</div>
)
}
interface StatusOptionProps {
highlightOffset: number
highlightIndex: number
colorEditingStatus: string | null
onSelect: (status: string) => void
onHighlight: (index: number) => void
onToggleColor: (status: string) => void
onColorChange: (status: string, colorKey: string) => void
}
function StatusOptionList({ statuses, ...props }: StatusOptionProps & { statuses: string[] }) {
return (
<>
{statuses.map((status, i) => (
<StatusOption
key={status}
status={status}
highlighted={props.highlightIndex === props.highlightOffset + i}
onSelect={props.onSelect}
onMouseEnter={() => props.onHighlight(props.highlightOffset + i)}
colorEditing={props.colorEditingStatus === status}
onToggleColor={props.onToggleColor}
onColorChange={props.onColorChange}
/>
))}
</>
)
}
function useStatusFiltering(query: string, vaultStatuses: string[]) {
return useMemo(() => {
const lowerQuery = query.toLowerCase()
const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase()))
const suggested = SUGGESTED_STATUSES.filter(
s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()),
)
const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery))
return { suggestedFiltered: suggested, vaultFiltered: vault, allFiltered: [...vault, ...suggested] }
}, [query, vaultStatuses])
}
interface KeyboardNavOptions {
allFiltered: string[]
totalOptions: number
showCreateOption: boolean
query: string
onSave: (v: string) => void
onCancel: () => void
listRef: React.RefObject<HTMLDivElement | null>
}
function useStatusKeyboard(opts: KeyboardNavOptions) {
const { allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef } = opts
const [highlightIndex, setHighlightIndex] = useState(-1)
const scrollIntoView = useCallback((index: number) => {
const list = listRef.current
if (!list) return
const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]')
items[index]?.scrollIntoView({ block: 'nearest' })
}, [listRef])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
setHighlightIndex(next)
scrollIntoView(next)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
setHighlightIndex(prev)
scrollIntoView(prev)
break
}
case 'Enter': {
e.preventDefault()
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) onSave(allFiltered[highlightIndex])
else if (showCreateOption && highlightIndex === allFiltered.length) onSave(trimmed)
else if (trimmed) onSave(trimmed)
break
}
case 'Escape':
e.preventDefault()
onCancel()
break
}
},
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollIntoView],
)
const resetHighlight = useCallback(() => setHighlightIndex(-1), [])
return { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight }
}
export function StatusDropdown({
vaultStatuses,
onSave,
@@ -63,188 +225,159 @@ export function StatusDropdown({
onCancel: () => void
}) {
const [query, setQuery] = useState('')
const [highlightIndex, setHighlightIndex] = useState(-1)
const [colorEditingStatus, setColorEditingStatus] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
inputRef.current?.focus()
useLayoutEffect(() => {
const node = dropdownRef.current
if (!node) return
const anchor = anchorRef.current?.parentElement
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const dropW = 208
let left = rect.right - dropW
if (left < 8) left = 8
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
const { suggestedFiltered, vaultFiltered, allFiltered } = useMemo(() => {
const lowerQuery = query.toLowerCase()
const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase()))
useEffect(() => { inputRef.current?.focus() }, [])
const suggested = SUGGESTED_STATUSES.filter(
s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()),
)
const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery))
return {
suggestedFiltered: suggested,
vaultFiltered: vault,
allFiltered: [...vault, ...suggested],
}
}, [query, vaultStatuses])
const { suggestedFiltered, vaultFiltered, allFiltered } = useStatusFiltering(query, vaultStatuses)
const showCreateOption = useMemo(() => {
if (!query.trim()) return false
const lowerQuery = query.trim().toLowerCase()
return !allFiltered.some(s => s.toLowerCase() === lowerQuery)
return !allFiltered.some(s => s.toLowerCase() === query.trim().toLowerCase())
}, [query, allFiltered])
const totalOptions = allFiltered.length + (showCreateOption ? 1 : 0)
const scrollHighlightedIntoView = useCallback((index: number) => {
const list = listRef.current
if (!list) return
const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]')
items[index]?.scrollIntoView({ block: 'nearest' })
const { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight } =
useStatusKeyboard({ allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef })
const handleToggleColor = useCallback((status: string) => {
setColorEditingStatus(prev => prev === status ? null : status)
}, [])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
setHighlightIndex(next)
scrollHighlightedIntoView(next)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
setHighlightIndex(prev)
scrollHighlightedIntoView(prev)
} else if (e.key === 'Enter') {
e.preventDefault()
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) {
onSave(allFiltered[highlightIndex])
} else if (showCreateOption && highlightIndex === allFiltered.length) {
onSave(query.trim())
} else if (query.trim()) {
onSave(query.trim())
}
} else if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
},
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollHighlightedIntoView],
)
const handleColorChange = useCallback((status: string, colorKey: string) => {
const currentKey = getStatusColorKey(status)
setStatusColor(status, currentKey === colorKey ? null : colorKey)
setColorEditingStatus(null)
}, [])
const handleQueryChange = useCallback((value: string) => {
setQuery(value)
resetHighlight()
}, [resetHighlight])
const optionProps: StatusOptionProps = {
highlightOffset: 0,
highlightIndex,
colorEditingStatus,
onSelect: onSave,
onHighlight: setHighlightIndex,
onToggleColor: handleToggleColor,
onColorChange: handleColorChange,
}
return (
<div className="relative" data-testid="status-dropdown">
{/* Backdrop to close on outside click */}
<div className="fixed inset-0 z-[12000]" onClick={onCancel} data-testid="status-dropdown-backdrop" />
<div
className="absolute right-0 top-full z-[12001] mt-1 w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
data-testid="status-dropdown-popover"
>
{/* Search input */}
<div className="border-b border-border px-2 py-1.5">
<input
ref={inputRef}
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
placeholder="Type a status..."
value={query}
onChange={e => {
setQuery(e.target.value)
setHighlightIndex(-1)
}}
onKeyDown={handleKeyDown}
data-testid="status-search-input"
/>
</div>
{/* Options list */}
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
{vaultFiltered.length > 0 && (
<div>
<div className="px-2 py-1">
<span
className="text-muted-foreground"
style={{
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
}}
>
From vault
</span>
</div>
{vaultFiltered.map((status, i) => (
<StatusOption
key={status}
status={status}
highlighted={highlightIndex === i}
onSelect={onSave}
onMouseEnter={() => setHighlightIndex(i)}
/>
))}
<span ref={anchorRef} data-testid="status-dropdown">
{createPortal(
<>
<div className="fixed inset-0 z-[12000]" onClick={onCancel} data-testid="status-dropdown-backdrop" />
<div
ref={dropdownRef}
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
data-testid="status-dropdown-popover"
>
<div className="border-b border-border px-2 py-1.5">
<input
ref={inputRef}
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
placeholder="Type a status..."
value={query}
onChange={e => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
data-testid="status-search-input"
/>
</div>
)}
{suggestedFiltered.length > 0 && (
<div>
{vaultFiltered.length > 0 && (
<div className="my-1 h-px bg-border" />
)}
<div className="px-2 py-1">
<span
className="text-muted-foreground"
style={{
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
}}
>
Suggested
</span>
</div>
{suggestedFiltered.map((status, i) => (
<StatusOption
key={status}
status={status}
highlighted={highlightIndex === vaultFiltered.length + i}
onSelect={onSave}
onMouseEnter={() => setHighlightIndex(vaultFiltered.length + i)}
/>
))}
</div>
)}
{showCreateOption && (
<>
{allFiltered.length > 0 && <div className="my-1 h-px bg-border" />}
<button
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{
borderRadius: 4,
backgroundColor:
highlightIndex === allFiltered.length ? 'var(--muted)' : 'transparent',
color: 'var(--muted-foreground)',
}}
onClick={() => onSave(query.trim())}
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
<VaultSection statuses={vaultFiltered} {...optionProps} />
<SuggestedSection
statuses={suggestedFiltered}
showDivider={vaultFiltered.length > 0}
{...optionProps}
highlightOffset={vaultFiltered.length}
/>
<CreateSection
show={showCreateOption}
query={query}
showDivider={allFiltered.length > 0}
highlighted={highlightIndex === allFiltered.length}
onSave={onSave}
onMouseEnter={() => setHighlightIndex(allFiltered.length)}
data-testid="status-create-option"
>
Create <StatusPill status={query.trim()} />
</button>
</>
)}
{allFiltered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching statuses
/>
{allFiltered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching statuses
</div>
)}
</div>
)}
</div>
</div>
</div>
</>,
document.body
)}
</span>
)
}
function VaultSection({ statuses, ...props }: StatusOptionProps & { statuses: string[] }) {
if (statuses.length === 0) return null
return (
<div>
<SectionLabel>From vault</SectionLabel>
<StatusOptionList statuses={statuses} {...props} />
</div>
)
}
function SuggestedSection({ statuses, showDivider, ...props }: StatusOptionProps & { statuses: string[]; showDivider: boolean }) {
if (statuses.length === 0) return null
return (
<div>
{showDivider && <div className="my-1 h-px bg-border" />}
<SectionLabel>Suggested</SectionLabel>
<StatusOptionList statuses={statuses} {...props} />
</div>
)
}
function CreateSection({ show, query, showDivider, highlighted, onSave, onMouseEnter }: {
show: boolean; query: string; showDivider: boolean; highlighted: boolean
onSave: (v: string) => void; onMouseEnter: () => void
}) {
if (!show) return null
const trimmed = query.trim()
return (
<>
{showDivider && <div className="my-1 h-px bg-border" />}
<button
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
color: 'var(--muted-foreground)',
}}
onClick={() => onSave(trimmed)}
onMouseEnter={onMouseEnter}
data-testid="status-create-option"
>
Create <StatusPill status={trimmed} />
</button>
</>
)
}

View File

@@ -23,6 +23,7 @@ interface TabBarProps {
canGoForward?: boolean
onGoBack?: () => void
onGoForward?: () => void
leftPanelsCollapsed?: boolean
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
@@ -179,6 +180,7 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
}
const STATUS_DOT: Record<string, { color: string; testId: string; title: string; pulse?: boolean }> = {
unsaved: { color: 'var(--accent-blue, #3b82f6)', testId: 'tab-unsaved-indicator', title: 'Unsaved — Cmd+S to save' },
pendingSave: { color: 'var(--accent-green)', testId: 'tab-pending-save-indicator', title: 'Saving to disk…', pulse: true },
new: { color: 'var(--accent-green)', testId: 'tab-new-indicator', title: 'New (uncommitted)' },
modified: { color: 'var(--accent-orange)', testId: 'tab-modified-indicator', title: 'Modified (uncommitted)' },
@@ -235,7 +237,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
{isEditing ? (
<InlineTabEdit initialValue={tab.entry.title} onSave={onRenameSave} onCancel={onRenameCancel} />
) : (
<span className="truncate" onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
<span className="truncate" style={noteStatus === 'unsaved' ? { fontStyle: 'italic' } : undefined} onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
{tab.entry.title}
</span>
)}
@@ -325,7 +327,7 @@ function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
export const TabBar = memo(function TabBar({
tabs, activeTabPath, getNoteStatus, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
canGoBack, canGoForward, onGoBack, onGoForward,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
}: TabBarProps) {
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
const [editingPath, setEditingPath] = useState<string | null>(null)
@@ -334,7 +336,7 @@ export const TabBar = memo(function TabBar({
return (
<div
className="flex shrink-0 items-stretch"
style={{ height: 52, background: 'var(--sidebar)' } as React.CSSProperties}
style={{ height: 52, background: 'var(--sidebar)', paddingLeft: leftPanelsCollapsed ? 80 : 0 } as React.CSSProperties}
onDragLeave={handleBarDragLeave}
>
<NavButtons canGoBack={canGoBack} canGoForward={canGoForward} onGoBack={onGoBack} onGoForward={onGoForward} />

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TagPill, TagsDropdown } from './TagsDropdown'
describe('TagPill', () => {
it('renders tag text', () => {
render(<TagPill tag="React" />)
const pill = screen.getByTitle('React')
expect(pill).toBeInTheDocument()
expect(pill.textContent).toBe('React')
})
it('renders with default style (blue)', () => {
render(<TagPill tag="Unknown" />)
const pill = screen.getByTitle('Unknown')
expect(pill.style.backgroundColor).toBe('var(--accent-blue-light)')
expect(pill.style.color).toBe('var(--accent-blue)')
})
it('applies truncate for long names', () => {
render(<TagPill tag="Very Long Tag Name That Should Truncate" />)
const pill = screen.getByTitle('Very Long Tag Name That Should Truncate')
expect(pill.className).toContain('truncate')
})
})
describe('TagsDropdown', () => {
const onToggle = vi.fn()
const onClose = vi.fn()
const defaultProps = {
selectedTags: ['React', 'Tauri'],
vaultTags: ['React', 'TypeScript', 'Tauri', 'Vite'],
onToggle,
onClose,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('renders dropdown with search input', () => {
render(<TagsDropdown {...defaultProps} />)
expect(screen.getByTestId('tags-dropdown')).toBeInTheDocument()
expect(screen.getByTestId('tags-search-input')).toBeInTheDocument()
})
it('shows vault tags as options', () => {
render(<TagsDropdown {...defaultProps} />)
expect(screen.getByTestId('tag-option-React')).toBeInTheDocument()
expect(screen.getByTestId('tag-option-TypeScript')).toBeInTheDocument()
expect(screen.getByTestId('tag-option-Tauri')).toBeInTheDocument()
expect(screen.getByTestId('tag-option-Vite')).toBeInTheDocument()
})
it('shows "From vault" section label', () => {
render(<TagsDropdown {...defaultProps} />)
expect(screen.getByText('From vault')).toBeInTheDocument()
})
it('shows checkmark for selected tags', () => {
render(<TagsDropdown {...defaultProps} />)
// React and Tauri are selected — their check marks should show
const reactOption = screen.getByTestId('tag-option-React').closest('div')!
const checkSpans = reactOption.querySelectorAll('span')
const hasCheck = Array.from(checkSpans).some(s => s.textContent === '\u2713')
expect(hasCheck).toBe(true)
})
it('calls onToggle when a tag option is clicked', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('tag-option-TypeScript'))
expect(onToggle).toHaveBeenCalledWith('TypeScript')
})
it('calls onClose when backdrop is clicked', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('tags-dropdown-backdrop'))
expect(onClose).toHaveBeenCalled()
})
it('filters tags by search query', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'type' } })
expect(screen.getByTestId('tag-option-TypeScript')).toBeInTheDocument()
expect(screen.queryByTestId('tag-option-Vite')).not.toBeInTheDocument()
})
it('shows "Create" option when query does not match any vault tag', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'NewTag' } })
expect(screen.getByTestId('tag-create-option')).toBeInTheDocument()
expect(screen.getByTestId('tag-create-option').textContent).toContain('Create')
expect(screen.getByTestId('tag-create-option').textContent).toContain('NewTag')
})
it('does not show create option when query matches an existing tag (case-insensitive)', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'react' } })
expect(screen.queryByTestId('tag-create-option')).not.toBeInTheDocument()
})
it('calls onToggle with new tag name when Create option is clicked', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.change(screen.getByTestId('tags-search-input'), { target: { value: 'Flutter' } })
fireEvent.click(screen.getByTestId('tag-create-option'))
expect(onToggle).toHaveBeenCalledWith('Flutter')
})
it('shows "No matching tags" when filter yields no results and query is empty-ish', () => {
render(<TagsDropdown {...defaultProps} vaultTags={[]} />)
expect(screen.getByText('No matching tags')).toBeInTheDocument()
})
it('shows color swatch for each tag option', () => {
render(<TagsDropdown {...defaultProps} />)
expect(screen.getByTestId('tag-color-swatch-React')).toBeInTheDocument()
expect(screen.getByTestId('tag-color-swatch-TypeScript')).toBeInTheDocument()
})
it('opens color picker when color swatch is clicked', () => {
render(<TagsDropdown {...defaultProps} />)
fireEvent.click(screen.getByTestId('tag-color-swatch-React'))
expect(screen.getByTestId('tag-color-picker-React')).toBeInTheDocument()
})
it('navigates options with arrow keys', () => {
render(<TagsDropdown {...defaultProps} />)
const input = screen.getByTestId('tags-search-input')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onToggle).toHaveBeenCalledWith('React')
})
it('closes dropdown on Escape key', () => {
render(<TagsDropdown {...defaultProps} />)
const input = screen.getByTestId('tags-search-input')
fireEvent.keyDown(input, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
})
it('creates new tag on Enter when query is typed and no highlight', () => {
render(<TagsDropdown {...defaultProps} />)
const input = screen.getByTestId('tags-search-input')
fireEvent.change(input, { target: { value: 'Brand New' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onToggle).toHaveBeenCalledWith('Brand New')
})
})

View File

@@ -0,0 +1,302 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getTagStyle, setTagColor, getTagColorKey } from '../utils/tagStyles'
import { ACCENT_COLORS } from '../utils/typeColors'
export function TagPill({ tag, className }: { tag: string; className?: string }) {
const style = getTagStyle(tag)
return (
<span
className={`inline-block min-w-0 truncate${className ? ` ${className}` : ''}`}
style={{
backgroundColor: style.bg,
color: style.color,
borderRadius: 16,
padding: '1px 6px',
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
maxWidth: 160,
}}
title={tag}
>
{tag}
</span>
)
}
function ColorPickerRow({ tag, onColorChange }: { tag: string; onColorChange: (tag: string, colorKey: string) => void }) {
const currentKey = getTagColorKey(tag)
return (
<div className="flex items-center gap-1 px-3 py-1.5" data-testid={`tag-color-picker-${tag}`}>
{ACCENT_COLORS.map(c => (
<button
key={c.key}
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0 transition-transform hover:scale-125"
style={{ backgroundColor: c.css }}
onClick={(e) => { e.stopPropagation(); onColorChange(tag, c.key) }}
title={c.label}
data-testid={`tag-color-option-${c.key}`}
>
{currentKey === c.key && (
<span style={{ color: 'white', fontSize: 8, lineHeight: 1 }}>{'\u2713'}</span>
)}
</button>
))}
</div>
)
}
function TagOption({
tag, selected, highlighted, onToggle, onMouseEnter,
colorEditing, onToggleColor, onColorChange,
}: {
tag: string; selected: boolean; highlighted: boolean
onToggle: (tag: string) => void; onMouseEnter: () => void
colorEditing: boolean
onToggleColor: (tag: string) => void; onColorChange: (tag: string, colorKey: string) => void
}) {
const style = getTagStyle(tag)
return (
<>
<div
className="flex w-full items-center gap-1 px-2 py-1 transition-colors"
style={{ borderRadius: 4, backgroundColor: highlighted ? 'var(--muted)' : 'transparent' }}
onMouseEnter={onMouseEnter}
>
<button
className="flex min-w-0 flex-1 items-center gap-1.5 border-none bg-transparent p-0 text-left"
onClick={() => onToggle(tag)}
data-testid={`tag-option-${tag}`}
>
<span className="w-3.5 text-center text-[10px]" style={{ color: style.color }}>
{selected ? '\u2713' : ''}
</span>
<TagPill tag={tag} />
</button>
<button
className="flex size-4 shrink-0 items-center justify-center rounded-full border-none p-0"
style={{ backgroundColor: style.color }}
onClick={() => onToggleColor(tag)}
title="Change color"
data-testid={`tag-color-swatch-${tag}`}
/>
</div>
{colorEditing && <ColorPickerRow tag={tag} onColorChange={onColorChange} />}
</>
)
}
const SECTION_LABEL_STYLE = {
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="px-2 py-1">
<span className="text-muted-foreground" style={SECTION_LABEL_STYLE}>{children}</span>
</div>
)
}
function useTagFiltering(query: string, vaultTags: string[]) {
return useMemo(() => {
const lowerQuery = query.toLowerCase()
const filtered = vaultTags.filter(t => t.toLowerCase().includes(lowerQuery))
return { filtered }
}, [query, vaultTags])
}
function useTagKeyboard(opts: {
filtered: string[]; totalOptions: number; showCreateOption: boolean
query: string; selectedTags: Set<string>
onToggle: (tag: string) => void; onClose: () => void
listRef: React.RefObject<HTMLDivElement | null>
}) {
const { filtered, totalOptions, showCreateOption, query, selectedTags, onToggle, onClose, listRef } = opts
const [highlightIndex, setHighlightIndex] = useState(-1)
const scrollIntoView = useCallback((index: number) => {
const list = listRef.current
if (!list) return
const items = list.querySelectorAll('[data-testid^="tag-option-"], [data-testid="tag-create-option"]')
items[index]?.scrollIntoView({ block: 'nearest' })
}, [listRef])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
setHighlightIndex(next)
scrollIntoView(next)
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
setHighlightIndex(prev)
scrollIntoView(prev)
break
}
case 'Enter': {
e.preventDefault()
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < filtered.length) {
onToggle(filtered[highlightIndex])
} else if (showCreateOption && highlightIndex === filtered.length && trimmed) {
onToggle(trimmed)
} else if (trimmed && !selectedTags.has(trimmed)) {
onToggle(trimmed)
}
break
}
case 'Escape':
e.preventDefault()
onClose()
break
}
},
[highlightIndex, totalOptions, filtered, showCreateOption, query, selectedTags, onToggle, onClose, scrollIntoView],
)
const resetHighlight = useCallback(() => setHighlightIndex(-1), [])
return { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight }
}
export function TagsDropdown({
selectedTags, vaultTags, onToggle, onClose,
}: {
selectedTags: string[]; vaultTags: string[]
onToggle: (tag: string) => void; onClose: () => void
}) {
const [query, setQuery] = useState('')
const [colorEditingTag, setColorEditingTag] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags])
useLayoutEffect(() => {
const node = dropdownRef.current
if (!node) return
const anchor = anchorRef.current?.parentElement
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const dropW = 208
let left = rect.right - dropW
if (left < 8) left = 8
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
useEffect(() => { inputRef.current?.focus() }, [])
const { filtered } = useTagFiltering(query, vaultTags)
const showCreateOption = useMemo(() => {
if (!query.trim()) return false
return !filtered.some(t => t.toLowerCase() === query.trim().toLowerCase())
}, [query, filtered])
const totalOptions = filtered.length + (showCreateOption ? 1 : 0)
const { highlightIndex, setHighlightIndex, handleKeyDown, resetHighlight } =
useTagKeyboard({ filtered, totalOptions, showCreateOption, query, selectedTags: selectedSet, onToggle, onClose, listRef })
const handleToggleColor = useCallback((tag: string) => {
setColorEditingTag(prev => prev === tag ? null : tag)
}, [])
const handleColorChange = useCallback((tag: string, colorKey: string) => {
const currentKey = getTagColorKey(tag)
setTagColor(tag, currentKey === colorKey ? null : colorKey)
setColorEditingTag(null)
}, [])
const handleQueryChange = useCallback((value: string) => {
setQuery(value)
resetHighlight()
}, [resetHighlight])
return (
<span ref={anchorRef} data-testid="tags-dropdown">
{createPortal(
<>
<div className="fixed inset-0 z-[12000]" onClick={onClose} data-testid="tags-dropdown-backdrop" />
<div
ref={dropdownRef}
className="fixed z-[12001] w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
data-testid="tags-dropdown-popover"
>
<div className="border-b border-border px-2 py-1.5">
<input
ref={inputRef}
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
placeholder="Type a tag..."
value={query}
onChange={e => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
data-testid="tags-search-input"
/>
</div>
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
{filtered.length > 0 && (
<div>
<SectionLabel>From vault</SectionLabel>
{filtered.map((tag, i) => (
<TagOption
key={tag} tag={tag}
selected={selectedSet.has(tag)}
highlighted={highlightIndex === i}
onToggle={onToggle}
onMouseEnter={() => setHighlightIndex(i)}
colorEditing={colorEditingTag === tag}
onToggleColor={handleToggleColor}
onColorChange={handleColorChange}
/>
))}
</div>
)}
{showCreateOption && (
<>
{filtered.length > 0 && <div className="my-1 h-px bg-border" />}
<button
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlightIndex === filtered.length ? 'var(--muted)' : 'transparent',
color: 'var(--muted-foreground)',
}}
onClick={() => onToggle(query.trim())}
onMouseEnter={() => setHighlightIndex(filtered.length)}
data-testid="tag-create-option"
>
Create <TagPill tag={query.trim()} />
</button>
</>
)}
{filtered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching tags
</div>
)}
</div>
</div>
</>,
document.body,
)}
</span>
)
}

View File

@@ -0,0 +1,110 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { WelcomeScreen } from './WelcomeScreen'
const defaultProps = {
mode: 'welcome' as const,
defaultVaultPath: '~/Documents/Laputa',
onCreateVault: vi.fn(),
onOpenFolder: vi.fn(),
creating: false,
error: null,
}
describe('WelcomeScreen', () => {
describe('welcome mode', () => {
it('renders welcome title and subtitle', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText('Welcome to Laputa')).toBeInTheDocument()
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
})
it('shows create vault and open folder buttons', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Create Getting Started vault')
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open an existing folder')
})
it('shows default vault path hint', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText(/will be created in/)).toBeInTheDocument()
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()
})
it('calls onCreateVault when create button is clicked', () => {
const onCreateVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateVault={onCreateVault} />)
fireEvent.click(screen.getByTestId('welcome-create-vault'))
expect(onCreateVault).toHaveBeenCalledOnce()
})
it('calls onOpenFolder when open folder button is clicked', () => {
const onOpenFolder = vi.fn()
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
fireEvent.click(screen.getByTestId('welcome-open-folder'))
expect(onOpenFolder).toHaveBeenCalledOnce()
})
it('disables buttons while creating', () => {
render(<WelcomeScreen {...defaultProps} creating={true} />)
expect(screen.getByTestId('welcome-create-vault')).toBeDisabled()
expect(screen.getByTestId('welcome-open-folder')).toBeDisabled()
})
it('shows loading text while creating', () => {
render(<WelcomeScreen {...defaultProps} creating={true} />)
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Creating vault…')
})
it('shows error message when error is set', () => {
render(<WelcomeScreen {...defaultProps} error="Permission denied" />)
expect(screen.getByTestId('welcome-error')).toHaveTextContent('Permission denied')
})
it('does not show error when error is null', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.queryByTestId('welcome-error')).not.toBeInTheDocument()
})
it('does not show path badge in welcome mode', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.queryByText('~/Laputa')).not.toBeInTheDocument()
})
})
describe('vault-missing mode', () => {
const missingProps = {
...defaultProps,
mode: 'vault-missing' as const,
missingPath: '~/Laputa',
}
it('renders vault not found title', () => {
render(<WelcomeScreen {...missingProps} />)
expect(screen.getByText('Vault not found')).toBeInTheDocument()
expect(screen.getByText(/could not be found on disk/)).toBeInTheDocument()
})
it('shows the missing vault path in a badge', () => {
render(<WelcomeScreen {...missingProps} />)
expect(screen.getByText('~/Laputa')).toBeInTheDocument()
})
it('shows "Choose a different folder" instead of "Open an existing folder"', () => {
render(<WelcomeScreen {...missingProps} />)
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Choose a different folder')
})
it('does not show vault path hint in vault-missing mode', () => {
render(<WelcomeScreen {...missingProps} />)
expect(screen.queryByText(/will be created in/)).not.toBeInTheDocument()
})
})
describe('data-testid', () => {
it('has welcome-screen container testid', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,206 @@
import { useState } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2 } from 'lucide-react'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
missingPath?: string
defaultVaultPath: string
onCreateVault: () => void
onOpenFolder: () => void
creating: boolean
error: string | null
}
const CONTAINER_STYLE: React.CSSProperties = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--sidebar)',
}
const CARD_STYLE: React.CSSProperties = {
width: 520,
background: 'var(--background)',
borderRadius: 12,
border: '1px solid var(--border)',
padding: 48,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 24,
}
const ICON_WRAP_STYLE: React.CSSProperties = {
width: 64,
height: 64,
borderRadius: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}
const TITLE_STYLE: React.CSSProperties = {
fontSize: 28,
fontWeight: 700,
letterSpacing: -0.5,
color: 'var(--foreground)',
textAlign: 'center',
margin: 0,
}
const SUBTITLE_STYLE: React.CSSProperties = {
fontSize: 14,
lineHeight: 1.6,
color: 'var(--muted-foreground)',
textAlign: 'center',
margin: 0,
}
const DIVIDER_STYLE: React.CSSProperties = {
width: '100%',
height: 1,
background: 'var(--border)',
}
const PRIMARY_BTN_STYLE: React.CSSProperties = {
width: '100%',
height: 44,
borderRadius: 8,
border: 'none',
background: 'var(--primary)',
color: 'white',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}
const SECONDARY_BTN_STYLE: React.CSSProperties = {
width: '100%',
height: 44,
borderRadius: 8,
border: '1px solid var(--border)',
background: 'var(--background)',
color: 'var(--foreground)',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}
const HINT_STYLE: React.CSSProperties = {
fontSize: 12,
color: 'var(--muted-foreground)',
textAlign: 'center',
margin: 0,
}
const PATH_BADGE_STYLE: React.CSSProperties = {
width: '100%',
background: 'var(--sidebar)',
borderRadius: 6,
padding: '8px 12px',
textAlign: 'center',
}
const ERROR_STYLE: React.CSSProperties = {
fontSize: 13,
color: 'var(--destructive, #e03e3e)',
textAlign: 'center',
margin: 0,
}
export function WelcomeScreen({ mode, missingPath, defaultVaultPath, onCreateVault, onOpenFolder, creating, error }: WelcomeScreenProps) {
const [hoverPrimary, setHoverPrimary] = useState(false)
const [hoverSecondary, setHoverSecondary] = useState(false)
const isWelcome = mode === 'welcome'
return (
<div style={CONTAINER_STYLE} data-testid="welcome-screen">
<div style={CARD_STYLE}>
<div
style={{
...ICON_WRAP_STYLE,
background: isWelcome ? 'var(--accent-blue-light, #EBF4FF)' : 'var(--accent-yellow-light, #FFF3E0)',
}}
>
{isWelcome
? <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}></span>
: <AlertTriangle size={28} style={{ color: 'var(--accent-orange)' }} />
}
</div>
<div style={{ textAlign: 'center' }}>
<h1 style={TITLE_STYLE}>
{isWelcome ? 'Welcome to Laputa' : 'Vault not found'}
</h1>
<p style={{ ...SUBTITLE_STYLE, marginTop: 8 }}>
{isWelcome
? 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.'
: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.'
}
</p>
</div>
{!isWelcome && missingPath && (
<div style={PATH_BADGE_STYLE}>
<code style={{ fontSize: 12, color: 'var(--muted-foreground)', fontFamily: 'var(--font-mono, monospace)' }}>
{missingPath}
</code>
</div>
)}
<div style={DIVIDER_STYLE} />
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<button
style={{
...PRIMARY_BTN_STYLE,
opacity: creating ? 0.7 : hoverPrimary ? 0.9 : 1,
}}
onClick={onCreateVault}
disabled={creating}
onMouseEnter={() => setHoverPrimary(true)}
onMouseLeave={() => setHoverPrimary(false)}
data-testid="welcome-create-vault"
>
{creating ? <Loader2 size={16} className="animate-spin" /> : <Plus size={16} />}
{creating ? 'Creating vault…' : 'Create Getting Started vault'}
</button>
<button
style={{
...SECONDARY_BTN_STYLE,
background: hoverSecondary ? 'var(--sidebar)' : 'var(--background)',
}}
onClick={onOpenFolder}
disabled={creating}
onMouseEnter={() => setHoverSecondary(true)}
onMouseLeave={() => setHoverSecondary(false)}
data-testid="welcome-open-folder"
>
<FolderOpen size={16} />
{isWelcome ? 'Open an existing folder' : 'Choose a different folder'}
</button>
</div>
{error && <p style={ERROR_STYLE} data-testid="welcome-error">{error}</p>}
{isWelcome && !error && (
<p style={HINT_STYLE}>
The Getting Started vault will be created in {defaultVaultPath}
</p>
)}
</div>
</div>
)
}

View File

@@ -19,6 +19,7 @@ function PopoverContent({
className,
align = "center",
sideOffset = 4,
collisionPadding = 8,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
@@ -27,8 +28,9 @@ function PopoverContent({
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
collisionPadding={collisionPadding}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[12000] w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}

View File

@@ -53,6 +53,7 @@ function SelectContent({
children,
position = "item-aligned",
align = "center",
collisionPadding = 8,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
@@ -60,13 +61,14 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[12000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
collisionPadding={collisionPadding}
{...props}
>
<SelectScrollUpButton />

View File

@@ -21,6 +21,7 @@ interface AppCommandsConfig {
onCommandPalette: () => void
onSearch: () => void
onCreateNote: () => void
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
onTrashNote: (path: string) => void
@@ -29,6 +30,10 @@ interface AppCommandsConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onCloseTab: (path: string) => void
onSwitchTab: (path: string) => void
@@ -52,6 +57,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onTrashNote: config.onTrashNote,
onArchiveNote: config.onArchiveNote,
onSetViewMode: config.onSetViewMode,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
activeTabPathRef: config.activeTabPathRef,
@@ -66,6 +74,9 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onOpenSettings: config.onOpenSettings,
onToggleInspector: config.onToggleInspector,
onCommandPalette: config.onCommandPalette,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,
@@ -77,6 +88,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
modifiedCount: config.modifiedCount,
onQuickOpen: config.onQuickOpen,
onCreateNote: config.onCreateNote,
onCreateNoteOfType: config.onCreateNoteOfType,
onSave: config.onSave,
onOpenSettings: config.onOpenSettings,
onTrashNote: config.onTrashNote,
@@ -85,6 +97,10 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCommitPush: config.onCommitPush,
onSetViewMode: config.onSetViewMode,
onToggleInspector: config.onToggleInspector,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
onZoomReset: config.onZoomReset,
zoomLevel: config.zoomLevel,
onSelect: config.onSelect,
onCloseTab: config.onCloseTab,
onGoBack: config.onGoBack,

View File

@@ -26,6 +26,9 @@ function makeActions() {
onTrashNote: vi.fn(),
onArchiveNote: vi.fn(),
onSetViewMode: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
}
@@ -143,4 +146,32 @@ describe('useAppKeyboard', () => {
expect(actions.onCommandPalette).toHaveBeenCalled()
})
})
it('Cmd+= triggers zoom in', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('=', { metaKey: true })
expect(actions.onZoomIn).toHaveBeenCalled()
})
it('Cmd++ triggers zoom in', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('+', { metaKey: true })
expect(actions.onZoomIn).toHaveBeenCalled()
})
it('Cmd+- triggers zoom out', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('-', { metaKey: true })
expect(actions.onZoomOut).toHaveBeenCalled()
})
it('Cmd+0 triggers zoom reset', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('0', { metaKey: true })
expect(actions.onZoomReset).toHaveBeenCalled()
})
})

View File

@@ -11,6 +11,9 @@ interface KeyboardActions {
onTrashNote: (path: string) => void
onArchiveNote: (path: string) => void
onSetViewMode: (mode: ViewMode) => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
onGoBack?: () => void
onGoForward?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
@@ -58,7 +61,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
export function useAppKeyboard({
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onSetViewMode, onGoBack, onGoForward, activeTabPathRef, handleCloseTabRef,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
useEffect(() => {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
@@ -78,6 +81,10 @@ export function useAppKeyboard({
Delete: withActiveTab(onTrashNote),
'[': () => onGoBack?.(),
']': () => onGoForward?.(),
'=': onZoomIn,
'+': onZoomIn,
'-': onZoomOut,
'0': onZoomReset,
}
const handleKeyDown = (e: KeyboardEvent) => {
@@ -93,5 +100,5 @@ export function useAppKeyboard({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onGoBack, onGoForward])
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward])
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useCommandRegistry, groupSortKey } from './useCommandRegistry'
import { useCommandRegistry, groupSortKey, pluralizeType, extractVaultTypes } from './useCommandRegistry'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
@@ -37,6 +37,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
modifiedCount: 0,
onQuickOpen: vi.fn(),
onCreateNote: vi.fn(),
onCreateNoteOfType: vi.fn(),
onSave: vi.fn(),
onOpenSettings: vi.fn(),
onTrashNote: vi.fn(),
@@ -45,6 +46,10 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
onCommitPush: vi.fn(),
onSetViewMode: vi.fn(),
onToggleInspector: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: vi.fn(),
zoomLevel: 100,
onSelect: vi.fn(),
onCloseTab: vi.fn(),
...overrides,
@@ -140,6 +145,199 @@ describe('useCommandRegistry', () => {
result.current.find(c => c.id === 'view-editor')!.execute()
expect(onSetViewMode).toHaveBeenCalledWith('editor-only')
})
it('zoom-in is enabled when below max zoom', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 100 })))
expect(result.current.find(c => c.id === 'zoom-in')!.enabled).toBe(true)
})
it('zoom-in is disabled at max zoom', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 150 })))
expect(result.current.find(c => c.id === 'zoom-in')!.enabled).toBe(false)
})
it('zoom-out is disabled at min zoom', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 80 })))
expect(result.current.find(c => c.id === 'zoom-out')!.enabled).toBe(false)
})
it('zoom-reset is disabled at 100%', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 100 })))
expect(result.current.find(c => c.id === 'zoom-reset')!.enabled).toBe(false)
})
it('zoom-reset is enabled when not at 100%', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 120 })))
expect(result.current.find(c => c.id === 'zoom-reset')!.enabled).toBe(true)
})
it('zoom-in label shows current zoom level', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ zoomLevel: 120 })))
expect(result.current.find(c => c.id === 'zoom-in')!.label).toContain('120%')
})
describe('type-aware commands', () => {
it('generates "New [Type]" commands from vault entries', () => {
const entries = [
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
makeEntry({ path: '/vault/person/alice.md', isA: 'Person' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
const newEvent = result.current.find(c => c.id === 'new-event')
const newPerson = result.current.find(c => c.id === 'new-person')
expect(newEvent).toBeDefined()
expect(newEvent!.label).toBe('New Event')
expect(newEvent!.group).toBe('Note')
expect(newPerson).toBeDefined()
expect(newPerson!.label).toBe('New Person')
})
it('generates "List [Type]" commands with pluralized names', () => {
const entries = [
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
makeEntry({ path: '/vault/person/alice.md', isA: 'Person' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
const listEvents = result.current.find(c => c.id === 'list-event')
const listPeople = result.current.find(c => c.id === 'list-person')
expect(listEvents).toBeDefined()
expect(listEvents!.label).toBe('List Events')
expect(listEvents!.group).toBe('Navigation')
expect(listPeople).toBeDefined()
expect(listPeople!.label).toBe('List People')
})
it('calls onCreateNoteOfType when "New [Type]" executes', () => {
const onCreateNoteOfType = vi.fn()
const entries = [makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' })]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries, onCreateNoteOfType })))
result.current.find(c => c.id === 'new-event')!.execute()
expect(onCreateNoteOfType).toHaveBeenCalledWith('Event')
})
it('calls onSelect with sectionGroup when "List [Type]" executes', () => {
const onSelect = vi.fn()
const entries = [makeEntry({ path: '/vault/person/alice.md', isA: 'Person' })]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries, onSelect })))
result.current.find(c => c.id === 'list-person')!.execute()
expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Person' })
})
it('uses default types when vault has no typed notes', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries: [] })))
expect(result.current.find(c => c.id === 'new-event')).toBeDefined()
expect(result.current.find(c => c.id === 'new-person')).toBeDefined()
expect(result.current.find(c => c.id === 'new-project')).toBeDefined()
expect(result.current.find(c => c.id === 'new-note')).toBeDefined()
})
it('excludes Type entries from vault type list', () => {
const entries = [
makeEntry({ path: '/vault/type/event.md', isA: 'Type', title: 'Event' }),
makeEntry({ path: '/vault/event/birthday.md', isA: 'Event' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
expect(result.current.find(c => c.id === 'new-type')).toBeUndefined()
})
it('excludes trashed entries from type extraction', () => {
const entries = [
makeEntry({ path: '/vault/event/old.md', isA: 'Event', trashed: true }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
// Should fall back to defaults since the only Event entry is trashed
expect(result.current.find(c => c.id === 'new-event')).toBeDefined()
expect(result.current.find(c => c.id === 'new-project')).toBeDefined()
})
it('deduplicates types from multiple entries', () => {
const entries = [
makeEntry({ path: '/vault/event/a.md', isA: 'Event' }),
makeEntry({ path: '/vault/event/b.md', isA: 'Event' }),
makeEntry({ path: '/vault/event/c.md', isA: 'Event' }),
]
const { result } = renderHook(() => useCommandRegistry(makeConfig({ entries })))
const eventCmds = result.current.filter(c => c.id === 'new-event')
expect(eventCmds).toHaveLength(1)
})
})
})
describe('pluralizeType', () => {
it('handles regular plurals', () => {
expect(pluralizeType('Event')).toBe('Events')
expect(pluralizeType('Project')).toBe('Projects')
expect(pluralizeType('Note')).toBe('Notes')
expect(pluralizeType('Topic')).toBe('Topics')
})
it('handles special plurals', () => {
expect(pluralizeType('Person')).toBe('People')
expect(pluralizeType('Responsibility')).toBe('Responsibilities')
})
it('handles words ending in y', () => {
expect(pluralizeType('Category')).toBe('Categories')
})
it('handles words ending in s/x/ch/sh', () => {
expect(pluralizeType('Process')).toBe('Processes')
expect(pluralizeType('Box')).toBe('Boxes')
})
it('does not double-pluralize words ending in vowel+y', () => {
expect(pluralizeType('Key')).toBe('Keys')
expect(pluralizeType('Day')).toBe('Days')
})
})
describe('extractVaultTypes', () => {
it('extracts unique types from entries', () => {
const entries = [
makeEntry({ isA: 'Event' }),
makeEntry({ isA: 'Person' }),
makeEntry({ isA: 'Event' }),
]
const types = extractVaultTypes(entries)
expect(types).toEqual(['Event', 'Person'])
})
it('returns default types when no entries', () => {
const types = extractVaultTypes([])
expect(types).toContain('Event')
expect(types).toContain('Person')
expect(types).toContain('Project')
expect(types).toContain('Note')
expect(types).toHaveLength(4)
})
it('excludes Type entries', () => {
const entries = [
makeEntry({ isA: 'Type' }),
makeEntry({ isA: 'Event' }),
]
const types = extractVaultTypes(entries)
expect(types).toEqual(['Event'])
expect(types).not.toContain('Type')
})
it('excludes trashed entries', () => {
const entries = [makeEntry({ isA: 'Event', trashed: true })]
const types = extractVaultTypes(entries)
// Falls back to defaults since the only typed entry is trashed
expect(types).toContain('Event')
expect(types).toContain('Person')
expect(types).toHaveLength(4)
})
it('returns sorted types', () => {
const entries = [
makeEntry({ isA: 'Procedure' }),
makeEntry({ isA: 'Event' }),
makeEntry({ isA: 'Note' }),
]
expect(extractVaultTypes(entries)).toEqual(['Event', 'Note', 'Procedure'])
})
})
describe('groupSortKey', () => {

View File

@@ -21,6 +21,7 @@ interface CommandRegistryConfig {
onQuickOpen: () => void
onCreateNote: () => void
onCreateNoteOfType: (type: string) => void
onSave: () => void
onOpenSettings: () => void
onTrashNote: (path: string) => void
@@ -29,6 +30,10 @@ interface CommandRegistryConfig {
onCommitPush: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
zoomLevel: number
onSelect: (sel: SidebarSelection) => void
onCloseTab: (path: string) => void
onGoBack?: () => void
@@ -37,18 +42,66 @@ interface CommandRegistryConfig {
canGoForward?: boolean
}
const PLURAL_OVERRIDES: Record<string, string> = {
Person: 'People',
Responsibility: 'Responsibilities',
}
const DEFAULT_TYPES = ['Event', 'Person', 'Project', 'Note']
export function pluralizeType(type: string): string {
if (PLURAL_OVERRIDES[type]) return PLURAL_OVERRIDES[type]
if (type.endsWith('s') || type.endsWith('x') || type.endsWith('ch') || type.endsWith('sh')) return `${type}es`
if (type.endsWith('y') && !/[aeiou]y$/i.test(type)) return `${type.slice(0, -1)}ies`
return `${type}s`
}
export function extractVaultTypes(entries: VaultEntry[]): string[] {
const typeSet = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Type' && !e.trashed) typeSet.add(e.isA)
}
if (typeSet.size === 0) return DEFAULT_TYPES
return Array.from(typeSet).sort()
}
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings']
export function groupSortKey(group: CommandGroup): number {
return GROUP_ORDER.indexOf(group)
}
export function buildTypeCommands(
types: string[],
onCreateNoteOfType: (type: string) => void,
onSelect: (sel: SidebarSelection) => void,
): CommandAction[] {
return types.flatMap((type) => {
const slug = type.toLowerCase().replace(/\s+/g, '-')
const plural = pluralizeType(type)
return [
{
id: `new-${slug}`, label: `New ${type}`, group: 'Note' as CommandGroup,
keywords: ['new', 'create', type.toLowerCase()],
enabled: true, execute: () => onCreateNoteOfType(type),
},
{
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as CommandGroup,
keywords: ['list', 'show', 'filter', type.toLowerCase(), plural.toLowerCase()],
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type }),
},
]
})
}
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
const {
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onSave, onOpenSettings,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onSelect, onCloseTab,
onCommitPush, onSetViewMode, onToggleInspector,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
} = config
@@ -60,6 +113,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
)
const isArchived = activeEntry?.archived ?? false
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
return useMemo(() => {
const cmds: CommandAction[] = [
// Navigation
@@ -92,17 +147,26 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
// Settings
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
]
return cmds
}, [
hasActiveNote, activeTabPath, isArchived, modifiedCount,
onQuickOpen, onCreateNote, onSave, onOpenSettings,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onSelect, onCloseTab,
onCommitPush, onSetViewMode, onToggleInspector,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes,
])
}

View File

@@ -13,6 +13,8 @@ interface EditorSaveConfig {
setTabs: (fn: SetStateAction<any[]>) => void
setToastMessage: (msg: string | null) => void
onAfterSave?: () => void
/** Called after content is persisted — used to clear unsaved state. */
onNotePersisted?: (path: string) => void
}
/**
@@ -21,7 +23,7 @@ interface EditorSaveConfig {
*/
const noop = () => {}
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop }: EditorSaveConfig) {
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNotePersisted }: EditorSaveConfig) {
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
const updateTabAndContent = useCallback((path: string, content: string) => {
@@ -40,20 +42,29 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
if (pathFilter && pending.path !== pathFilter) return false
await saveNote(pending.path, pending.content)
pendingContentRef.current = null
onNotePersisted?.(pending.path)
return true
}, [saveNote])
}, [saveNote, onNotePersisted])
/** Called by Cmd+S — persists the current editor content to disk */
const handleSave = useCallback(async () => {
/** Called by Cmd+S — persists the current editor content to disk.
* Accepts optional fallback for unsaved notes with no pending edits. */
const handleSave = useCallback(async (unsavedFallback?: { path: string; content: string }) => {
try {
const saved = await flushPending()
if (!saved && unsavedFallback) {
await saveNote(unsavedFallback.path, unsavedFallback.content)
onNotePersisted?.(unsavedFallback.path)
setToastMessage('Saved')
onAfterSave()
return
}
setToastMessage(saved ? 'Saved' : 'Nothing to save')
onAfterSave()
} catch (err) {
console.error('Save failed:', err)
setToastMessage(`Save failed: ${err}`)
}
}, [flushPending, setToastMessage, onAfterSave])
}, [flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
/** Called by Editor onChange — buffers the latest content without saving */
const handleContentChange = useCallback((path: string, content: string) => {

View File

@@ -0,0 +1,158 @@
import { describe, it, expect } from 'vitest'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './useEditorTabSwap'
describe('extractEditorBody', () => {
it('strips frontmatter and preserves H1 heading for new note content', () => {
const content = '---\ntitle: Untitled note\ntype: Note\nstatus: Active\n---\n\n# Untitled note\n\n'
expect(extractEditorBody(content)).toBe('# Untitled note\n\n')
})
it('strips frontmatter and preserves H1 with body content', () => {
const content = '---\ntitle: Test\n---\n# Test\n\nBody text here.'
expect(extractEditorBody(content)).toBe('# Test\n\nBody text here.')
})
it('preserves H1 and body content after frontmatter', () => {
const content = '---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nFirst paragraph.\n\nSecond paragraph.'
expect(extractEditorBody(content)).toBe('# My Note\n\nFirst paragraph.\n\nSecond paragraph.')
})
it('handles content without frontmatter', () => {
const content = '# Just a Heading\n\nSome body text.'
expect(extractEditorBody(content)).toBe('# Just a Heading\n\nSome body text.')
})
it('handles content without frontmatter or heading', () => {
const content = 'Just plain text.'
expect(extractEditorBody(content)).toBe('Just plain text.')
})
it('handles completely empty content', () => {
expect(extractEditorBody('')).toBe('')
})
it('handles frontmatter-only content', () => {
const content = '---\ntitle: Empty\n---\n'
expect(extractEditorBody(content)).toBe('')
})
it('preserves wikilinks in body', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nSee [[Other Note]] for details.'
expect(extractEditorBody(content)).toBe('# Test\n\nSee [[Other Note]] for details.')
})
it('preserves non-leading headings', () => {
const content = '---\ntitle: Test\n---\n\nSome intro text.\n\n# A Heading\n\nMore text.'
expect(extractEditorBody(content)).toBe('Some intro text.\n\n# A Heading\n\nMore text.')
})
it('preserves H1 for buildNoteContent output', () => {
const content = '---\ntitle: My Project\ntype: Project\nstatus: Active\n---\n\n# My Project\n\n'
expect(extractEditorBody(content)).toBe('# My Project\n\n')
})
})
describe('getH1TextFromBlocks', () => {
it('returns text from H1 heading block', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [{ type: 'text', text: 'My Title', styles: {} }],
}]
expect(getH1TextFromBlocks(blocks)).toBe('My Title')
})
it('returns null for empty blocks', () => {
expect(getH1TextFromBlocks([])).toBeNull()
})
it('returns null for non-heading first block', () => {
const blocks = [{
type: 'paragraph',
content: [{ type: 'text', text: 'Just text' }],
}]
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for H2 heading', () => {
const blocks = [{
type: 'heading',
props: { level: 2 },
content: [{ type: 'text', text: 'Subtitle' }],
}]
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('concatenates multiple text spans', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
],
}]
expect(getH1TextFromBlocks(blocks)).toBe('Hello World')
})
it('returns null for empty H1 content', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [],
}]
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for whitespace-only H1', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [{ type: 'text', text: ' ' }],
}]
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null when blocks is null/undefined', () => {
expect(getH1TextFromBlocks(null as unknown as unknown[])).toBeNull()
expect(getH1TextFromBlocks(undefined as unknown as unknown[])).toBeNull()
})
it('filters non-text inline content', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
],
}]
expect(getH1TextFromBlocks(blocks)).toBe('Title')
})
})
describe('replaceTitleInFrontmatter', () => {
it('replaces title value in frontmatter', () => {
const fm = '---\ntitle: Old Title\ntype: Note\n---\n\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\ntype: Note\n---\n\n')
})
it('handles title with extra spaces after colon', () => {
const fm = '---\ntitle: Old Title\n---\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\n---\n')
})
it('returns unchanged frontmatter when no title line exists', () => {
const fm = '---\ntype: Note\n---\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntype: Note\n---\n')
})
it('replaces only the title line, not other fields', () => {
const fm = '---\ntitle: Old\ntype: Note\nstatus: Active\n---\n\n'
expect(replaceTitleInFrontmatter(fm, 'Updated')).toBe('---\ntitle: Updated\ntype: Note\nstatus: Active\n---\n\n')
})
it('handles empty string as frontmatter', () => {
expect(replaceTitleInFrontmatter('', 'Title')).toBe('')
})
})

View File

@@ -1,3 +1,4 @@
import type React from 'react'
import { useCallback, useEffect, useRef } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
@@ -13,6 +14,39 @@ interface UseEditorTabSwapOptions {
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
onContentChange?: (path: string, content: string) => void
/** Called on every editor change with the H1 text (null if no H1 first block). */
onH1Change?: (h1Text: string | null) => void
/** When .current is false, handleEditorChange won't update frontmatter title from H1. */
syncActiveRef?: React.MutableRefObject<boolean>
}
/** Strip the YAML frontmatter from raw file content, returning the body
* (including any H1 heading) that should appear in the editor. */
export function extractEditorBody(rawFileContent: string): string {
const [, rawBody] = splitFrontmatter(rawFileContent)
return rawBody.trimStart()
}
/** Extract H1 text from the editor's first block, or null if not an H1. */
export function getH1TextFromBlocks(blocks: unknown[]): string | null {
if (!blocks?.length) return null
const first = blocks[0] as {
type?: string
props?: { level?: number }
content?: Array<{ type?: string; text?: string }>
}
if (first.type !== 'heading' || first.props?.level !== 1) return null
if (!Array.isArray(first.content)) return null
const text = first.content
.filter(item => item.type === 'text')
.map(item => item.text || '')
.join('')
return text.trim() || null
}
/** Replace the title: line in YAML frontmatter with a new title value. */
export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string): string {
return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`)
}
/**
@@ -26,7 +60,7 @@ interface UseEditorTabSwapOptions {
*
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
*/
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange }: UseEditorTabSwapOptions) {
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef }: UseEditorTabSwapOptions) {
// Cache parsed blocks per tab path for instant switching
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
const tabCacheRef = useRef<Map<string, any[]>>(new Map())
@@ -40,6 +74,8 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange
// Keep refs to callbacks for the onChange handler
const onContentChangeRef = useRef(onContentChange)
onContentChangeRef.current = onContentChange
const onH1ChangeRef = useRef(onH1Change)
onH1ChangeRef.current = onH1Change
const tabsRef = useRef(tabs)
tabsRef.current = tabs
@@ -77,13 +113,20 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = editor.blocksToMarkdownLossy(restored as typeof blocks)
// Reconstruct the full file: preserve original frontmatter + title heading
// Reconstruct full file: frontmatter + body (which now includes H1 if present)
const [frontmatter] = splitFrontmatter(tab.content)
const title = tab.entry.title
const fullContent = `${frontmatter}# ${title}\n\n${bodyMarkdown}`
const h1Text = getH1TextFromBlocks(blocks)
// Keep frontmatter title: in sync with H1 when sync is active
const isSyncActive = syncActiveRef?.current !== false
const fm = (isSyncActive && h1Text)
? replaceTitleInFrontmatter(frontmatter, h1Text)
: frontmatter
const fullContent = `${fm}${bodyMarkdown}`
onContentChangeRef.current?.(path, fullContent)
}, [editor])
onH1ChangeRef.current?.(h1Text)
}, [editor, syncActiveRef])
// Swap document content when active tab changes.
// Uses queueMicrotask to defer BlockNote mutations outside React's commit phase,
@@ -151,10 +194,32 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange
return
}
const [, rawBody] = splitFrontmatter(tab.content)
const body = rawBody.replace(/^# [^\n]*\n?/, '').trimStart()
const body = extractEditorBody(tab.content)
const preprocessed = preProcessWikilinks(body)
// Fast path: empty body (e.g. newly created notes). Skip the
// potentially-async markdown parser and set a single empty paragraph
// so the editor is immediately interactive.
if (!preprocessed.trim()) {
const emptyDoc = [{ type: 'paragraph', content: [] }]
cache.set(targetPath, emptyDoc)
applyBlocks(emptyDoc)
return
}
// Fast path: H1-only content (e.g. newly created notes that just have
// the title heading). Build blocks directly to stay instant.
const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/)
if (h1OnlyMatch) {
const h1Doc = [
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] },
{ type: 'paragraph', content: [], children: [] },
]
cache.set(targetPath, h1Doc)
applyBlocks(h1Doc)
return
}
try {
const result = editor.tryParseMarkdownToBlocks(preprocessed)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useHeadingTitleSync } from './useHeadingTitleSync'
describe('useHeadingTitleSync', () => {
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('calls onTitleSync after debounce when H1 differs from title', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old Title', onTitleSync })
)
act(() => { result.current.onH1Changed('New Title') })
expect(onTitleSync).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'New Title')
})
it('does not call onTitleSync when H1 matches current title', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Same Title', onTitleSync })
)
act(() => { result.current.onH1Changed('Same Title') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).not.toHaveBeenCalled()
})
it('does not call onTitleSync when H1 is null', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
)
act(() => { result.current.onH1Changed(null) })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).not.toHaveBeenCalled()
})
it('debounces rapid H1 changes and uses last value', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old', onTitleSync })
)
act(() => { result.current.onH1Changed('New 1') })
act(() => { vi.advanceTimersByTime(200) })
act(() => { result.current.onH1Changed('New 2') })
act(() => { vi.advanceTimersByTime(200) })
act(() => { result.current.onH1Changed('New 3') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).toHaveBeenCalledTimes(1)
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'New 3')
})
it('does not call onTitleSync when no active tab', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: null, currentTitle: null, onTitleSync })
)
act(() => { result.current.onH1Changed('Title') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).not.toHaveBeenCalled()
})
it('resets sync state when active tab changes', () => {
const onTitleSync = vi.fn()
const { result, rerender } = renderHook(
({ path, title }) => useHeadingTitleSync({ activeTabPath: path, currentTitle: title, onTitleSync }),
{ initialProps: { path: '/a.md', title: 'A' } }
)
// Break sync on tab A
act(() => { result.current.onManualRename('Custom', 'A H1') })
// H1 change should be ignored (sync broken)
act(() => { result.current.onH1Changed('New A') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).not.toHaveBeenCalled()
// Switch to tab B — sync resets
rerender({ path: '/b.md', title: 'B' })
// H1 change should now work
act(() => { result.current.onH1Changed('New B') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).toHaveBeenCalledWith('/b.md', 'New B')
})
it('breaks sync when manual rename differs from H1', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
)
act(() => { result.current.onManualRename('Custom Name', 'Title') })
// H1 changes should be ignored
act(() => { result.current.onH1Changed('New H1') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).not.toHaveBeenCalled()
})
it('does not break sync when manual rename matches H1', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old', onTitleSync })
)
act(() => { result.current.onManualRename('Same as H1', 'Same as H1') })
// Sync should still be active
act(() => { result.current.onH1Changed('Updated') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'Updated')
})
it('does not break sync when H1 is null during manual rename', () => {
const onTitleSync = vi.fn()
const { result } = renderHook(() =>
useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync })
)
act(() => { result.current.onManualRename('New Name', null) })
// Sync should still be active (no H1 to compare)
act(() => { result.current.onH1Changed('H1 Text') })
act(() => { vi.advanceTimersByTime(500) })
expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'H1 Text')
})
it('clears pending debounce timer on tab switch', () => {
const onTitleSync = vi.fn()
const { result, rerender } = renderHook(
({ path, title }) => useHeadingTitleSync({ activeTabPath: path, currentTitle: title, onTitleSync }),
{ initialProps: { path: '/a.md', title: 'A' } }
)
act(() => { result.current.onH1Changed('New A') })
// Switch tab before debounce fires
rerender({ path: '/b.md', title: 'B' })
act(() => { vi.advanceTimersByTime(500) })
// Should NOT fire for old tab
expect(onTitleSync).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef } from 'react'
interface HeadingTitleSyncConfig {
activeTabPath: string | null
currentTitle: string | null
onTitleSync: (path: string, newTitle: string) => void
}
const DEBOUNCE_MS = 500
/**
* Syncs the note title with the editor's first H1 heading.
*
* - On every editor change, the caller passes the current H1 text via onH1Changed.
* - After a 500ms debounce, VaultEntry.title is updated to match.
* - If the user manually renames the title (via tab) to something different from
* the H1, sync breaks and stops updating until the tab is switched.
* - If the H1 is deleted, the last synced title is kept (no clear).
*/
export function useHeadingTitleSync({
activeTabPath,
currentTitle,
onTitleSync,
}: HeadingTitleSyncConfig) {
const syncActiveRef = useRef(true)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const activeTabPathRef = useRef(activeTabPath)
// eslint-disable-next-line react-hooks/refs
activeTabPathRef.current = activeTabPath
const onTitleSyncRef = useRef(onTitleSync)
// eslint-disable-next-line react-hooks/refs
onTitleSyncRef.current = onTitleSync
const currentTitleRef = useRef(currentTitle)
// eslint-disable-next-line react-hooks/refs
currentTitleRef.current = currentTitle
// Reset sync state when switching tabs
useEffect(() => {
syncActiveRef.current = true
clearTimeout(debounceTimerRef.current)
}, [activeTabPath])
// Cleanup on unmount
useEffect(() => () => clearTimeout(debounceTimerRef.current), [])
/** Called on every editor change with the H1 text (null if no H1 first block). */
const onH1Changed = useCallback((h1Text: string | null) => {
if (!h1Text || !activeTabPathRef.current || !syncActiveRef.current) return
if (h1Text === currentTitleRef.current) return
clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(() => {
if (syncActiveRef.current && activeTabPathRef.current) {
onTitleSyncRef.current(activeTabPathRef.current, h1Text)
}
}, DEBOUNCE_MS)
}, [])
/** Called when the user manually renames via tab double-click.
* Breaks sync if the new title differs from the current H1. */
const onManualRename = useCallback((newTitle: string, currentH1: string | null) => {
if (currentH1 && currentH1 !== newTitle) {
syncActiveRef.current = false
}
}, [])
return { syncActiveRef, onH1Changed, onManualRename }
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
/**
* Registers Laputa as an MCP server in Claude Code and Cursor config files.
* Fires once per vault path (skips duplicates).
*/
export function useMcpRegistration(
vaultPath: string,
onToast: (msg: string) => void,
) {
const registeredRef = useRef<string | null>(null)
useEffect(() => {
if (registeredRef.current === vaultPath) return
registeredRef.current = vaultPath
tauriCall<string>('register_mcp_tools', { vaultPath })
.then((status) => {
if (status === 'registered') {
onToast('Laputa registered as MCP tool for Claude Code')
}
})
.catch(() => {
// Silently ignore — not critical for app operation
})
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- onToast is stable via ref
}

View File

@@ -10,6 +10,9 @@ function makeHandlers(): MenuEventHandlers {
onOpenSettings: vi.fn(),
onToggleInspector: vi.fn(),
onCommandPalette: vi.fn(),
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomReset: 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',
@@ -84,6 +87,24 @@ describe('dispatchMenuEvent', () => {
expect(h.onCommandPalette).toHaveBeenCalled()
})
it('view-zoom-in triggers zoom in', () => {
const h = makeHandlers()
dispatchMenuEvent('view-zoom-in', h)
expect(h.onZoomIn).toHaveBeenCalled()
})
it('view-zoom-out triggers zoom out', () => {
const h = makeHandlers()
dispatchMenuEvent('view-zoom-out', h)
expect(h.onZoomOut).toHaveBeenCalled()
})
it('view-zoom-reset triggers zoom reset', () => {
const h = makeHandlers()
dispatchMenuEvent('view-zoom-reset', h)
expect(h.onZoomReset).toHaveBeenCalled()
})
it('unknown event ID does nothing', () => {
const h = makeHandlers()
dispatchMenuEvent('unknown-event', h)

View File

@@ -10,6 +10,9 @@ export interface MenuEventHandlers {
onOpenSettings: () => void
onToggleInspector: () => void
onCommandPalette: () => void
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
@@ -21,23 +24,31 @@ const VIEW_MODE_MAP: Record<string, ViewMode> = {
'view-all': 'all',
}
type SimpleHandler = 'onCreateNote' | 'onQuickOpen' | 'onSave' | 'onOpenSettings' | 'onToggleInspector' | 'onCommandPalette' | 'onZoomIn' | 'onZoomOut' | 'onZoomReset'
const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
'file-new-note': 'onCreateNote',
'file-quick-open': 'onQuickOpen',
'file-save': 'onSave',
'app-settings': 'onOpenSettings',
'view-toggle-inspector': 'onToggleInspector',
'view-command-palette': 'onCommandPalette',
'view-zoom-in': 'onZoomIn',
'view-zoom-out': 'onZoomOut',
'view-zoom-reset': 'onZoomReset',
}
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
const viewMode = VIEW_MODE_MAP[id]
if (viewMode) { h.onSetViewMode(viewMode); return }
switch (id) {
case 'file-new-note': h.onCreateNote(); break
case 'file-quick-open': h.onQuickOpen(); break
case 'file-save': h.onSave(); break
case 'file-close-tab': {
const path = h.activeTabPathRef.current
if (path) h.handleCloseTabRef.current(path)
break
}
case 'app-settings': h.onOpenSettings(); break
case 'view-toggle-inspector': h.onToggleInspector(); break
case 'view-command-palette': h.onCommandPalette(); break
const simple = SIMPLE_EVENT_MAP[id]
if (simple) { h[simple](); return }
if (id === 'file-close-tab') {
const path = h.activeTabPathRef.current
if (path) h.handleCloseTabRef.current(path)
}
}

View File

@@ -7,10 +7,11 @@ export interface MultiSelectState {
toggle: (path: string) => void
selectRange: (toPath: string) => void
clear: () => void
setAnchor: (path: string) => void
selectAll: () => void
}
export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
export function useMultiSelect(visibleEntries: VaultEntry[], activePath: string | null = null): MultiSelectState {
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
const lastClickedRef = useRef<string | null>(null)
@@ -25,7 +26,7 @@ export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
}, [])
const selectRange = useCallback((toPath: string) => {
const fromPath = lastClickedRef.current
const fromPath = lastClickedRef.current ?? activePath
if (!fromPath) {
toggle(toPath)
return
@@ -45,13 +46,17 @@ export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
return next
})
lastClickedRef.current = toPath
}, [visibleEntries, toggle])
}, [visibleEntries, activePath, toggle])
const clear = useCallback(() => {
setSelectedPaths(new Set())
lastClickedRef.current = null
}, [])
const setAnchor = useCallback((path: string) => {
lastClickedRef.current = path
}, [])
const selectAll = useCallback(() => {
setSelectedPaths(new Set(visibleEntries.map((e) => e.path)))
}, [visibleEntries])
@@ -62,6 +67,7 @@ export function useMultiSelect(visibleEntries: VaultEntry[]): MultiSelectState {
toggle,
selectRange,
clear,
setAnchor,
selectAll,
}
}

View File

@@ -518,12 +518,12 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Failed to create note — disk write error')
})
it('handleCreateNoteImmediate works with pending save callbacks', async () => {
const addPendingSave = vi.fn()
const removePendingSave = vi.fn()
it('handleCreateNoteImmediate calls trackUnsaved and markContentPending (no disk write)', async () => {
const trackUnsaved = vi.fn()
const markContentPending = vi.fn()
const config = makeConfig()
config.addPendingSave = addPendingSave
config.removePendingSave = removePendingSave
config.trackUnsaved = trackUnsaved
config.markContentPending = markContentPending
const { result } = renderHook(() => useNoteActions(config))
@@ -532,8 +532,8 @@ describe('useNoteActions hook', () => {
await new Promise((r) => setTimeout(r, 0))
})
expect(addPendingSave).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
expect(removePendingSave).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'), expect.stringContaining('Untitled note'))
})
})
@@ -573,12 +573,7 @@ describe('useNoteActions hook', () => {
expect(setToastMessage).not.toHaveBeenCalled()
})
it('handles rapid creation with one failure independently', async () => {
vi.mocked(invoke)
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('disk full'))
.mockResolvedValueOnce(undefined)
it('handleCreateNoteImmediate does not call invoke (no disk write)', async () => {
const { result } = renderHook(() => useNoteActions(makeConfig()))
await act(async () => {
@@ -589,8 +584,34 @@ describe('useNoteActions hook', () => {
})
expect(addEntry).toHaveBeenCalledTimes(3)
expect(removeEntry).toHaveBeenCalledTimes(1)
expect(removeEntry).toHaveBeenCalledWith(expect.stringContaining('untitled-note-2.md'))
// No disk writes for immediate creation — notes are unsaved/in-memory
expect(invoke).not.toHaveBeenCalled()
expect(removeEntry).not.toHaveBeenCalled()
})
it('closing unsaved tab removes entry', () => {
const clearUnsaved = vi.fn()
const unsavedPaths = new Set<string>()
const config = makeConfig()
config.clearUnsaved = clearUnsaved
config.unsavedPaths = unsavedPaths
const { result } = renderHook(() => useNoteActions(config))
act(() => {
result.current.handleCreateNoteImmediate()
})
const createdPath = addEntry.mock.calls[0][0].path
unsavedPaths.add(createdPath) // simulate trackUnsaved
config.unsavedPaths = unsavedPaths // update ref
act(() => {
result.current.handleCloseTab(createdPath)
})
expect(removeEntry).toHaveBeenCalledWith(createdPath)
expect(clearUnsaved).toHaveBeenCalledWith(createdPath)
})
})
})

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
import type { VaultEntry } from '../types'
@@ -28,6 +28,11 @@ export interface NoteActionsConfig {
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
addPendingSave?: (path: string) => void
removePendingSave?: (path: string) => void
trackUnsaved?: (path: string) => void
clearUnsaved?: (path: string) => void
unsavedPaths?: Set<string>
/** Called when an unsaved note is created so the save system can buffer its initial content. */
markContentPending?: (path: string, content: string) => void
}
async function performRename(
@@ -263,10 +268,13 @@ async function runFrontmatterAndApply(
export function useNoteActions(config: NoteActionsConfig) {
const { addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config
const tabMgmt = useTabManagement()
const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, activeTabPathRef, handleSwitchTab } = tabMgmt
const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, handleCloseTabRef, activeTabPathRef, handleSwitchTab } = tabMgmt
const tabsRef = useRef(tabMgmt.tabs)
// eslint-disable-next-line react-hooks/refs
tabsRef.current = tabMgmt.tabs
const unsavedPathsRef = useRef(config.unsavedPaths)
// eslint-disable-next-line react-hooks/refs
unsavedPathsRef.current = config.unsavedPaths
const updateTabContent = useCallback((path: string, newContent: string) => {
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
@@ -300,10 +308,26 @@ export function useNoteActions(config: NoteActionsConfig) {
const noteType = type || 'Note'
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
pendingNamesRef.current.add(title)
handleCreateNote(title, noteType)
const resolved = resolveNewNote(title, noteType)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
config.trackUnsaved?.(resolved.entry.path)
config.markContentPending?.(resolved.entry.path, resolved.content)
signalFocusEditor()
setTimeout(() => pendingNamesRef.current.delete(title), 500)
}, [entries, handleCreateNote])
}, [entries, openTabWithContent, addEntry, 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) => {
if (unsavedPathsRef.current?.has(path)) {
removeEntry(path)
config.clearUnsaved?.(path)
}
handleCloseTab(path)
}, [handleCloseTab, removeEntry, config.clearUnsaved]) // eslint-disable-line react-hooks/exhaustive-deps -- ref access is stable
// Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes.
useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup })
const handleCreateType = useCallback((typeName: string) => {
createAndPersist(resolveNewType(typeName), addEntry, openTabWithContent, persistCbs)
@@ -340,6 +364,7 @@ export function useNoteActions(config: NoteActionsConfig) {
return {
...tabMgmt,
handleCloseTab: handleCloseTabWithCleanup,
handleNavigateWikilink,
handleCreateNote,
handleCreateNoteImmediate,

View File

@@ -69,12 +69,12 @@ describe('useNoteSearch', () => {
expect(project?.typeLightColor).toBeTruthy()
})
it('excludes noteType and light color for Note entries', () => {
it('includes noteType and colors for Note entries', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
const note = result.current.results.find((r) => r.title === 'Beta Notes')
expect(note?.noteType).toBeUndefined()
expect(note?.typeColor).toBeUndefined()
expect(note?.typeLightColor).toBeUndefined()
expect(note?.noteType).toBe('Note')
expect(note?.typeColor).toBeTruthy()
expect(note?.typeLightColor).toBeTruthy()
})
it('includes original VaultEntry in results', () => {

View File

@@ -12,7 +12,7 @@ export interface NoteSearchResult extends NoteSearchResultItem {
}
function toResult(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>): NoteSearchResult {
const noteType = e.isA && e.isA !== 'Note' ? e.isA : undefined
const noteType = e.isA || undefined
const te = typeEntryMap[e.isA ?? '']
return {
entry: e,

View File

@@ -0,0 +1,204 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// localStorage mock
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
const mockInvokeFn = vi.fn()
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
import { useOnboarding } from './useOnboarding'
import { pickFolder } from '../utils/vault-dialog'
describe('useOnboarding', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
it('transitions to ready when vault exists', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'check_vault_exists') return true
return null
})
const { result } = renderHook(() => useOnboarding('/vault/path'))
await waitFor(() => {
expect(result.current.state.status).toBe('ready')
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/path' })
})
it('shows welcome screen when vault does not exist', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'check_vault_exists') return false
return null
})
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Laputa' })
})
it('shows vault-missing when previously dismissed and vault gone', async () => {
localStorage.setItem('laputa_welcome_dismissed', '1')
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'check_vault_exists') return false
return null
})
const { result } = renderHook(() => useOnboarding('/vault/deleted'))
await waitFor(() => {
expect(result.current.state.status).toBe('vault-missing')
})
expect(result.current.state).toEqual({
status: 'vault-missing',
vaultPath: '/vault/deleted',
defaultPath: '/mock/Documents/Laputa',
})
})
it('handleCreateVault creates vault and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Laputa'
return null
})
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
await act(async () => {
await result.current.handleCreateVault()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Laputa' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
})
it('handleCreateVault sets error on failure', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') throw 'Permission denied'
return null
})
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
await act(async () => {
await result.current.handleCreateVault()
})
expect(result.current.error).toBe('Permission denied')
expect(result.current.state.status).toBe('welcome')
})
it('handleOpenFolder opens folder picker and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'check_vault_exists') return false
return null
})
vi.mocked(pickFolder).mockResolvedValue('/selected/folder')
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
await act(async () => {
await result.current.handleOpenFolder()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/selected/folder' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
})
it('handleOpenFolder does nothing when picker is cancelled', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'check_vault_exists') return false
return null
})
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
await act(async () => {
await result.current.handleOpenFolder()
})
expect(result.current.state.status).toBe('welcome')
})
it('handleDismiss marks dismissed and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'check_vault_exists') return false
return null
})
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
act(() => {
result.current.handleDismiss()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/missing' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
})
it('falls back to ready if commands fail', async () => {
mockInvokeFn.mockRejectedValue(new Error('command not found'))
const { result } = renderHook(() => useOnboarding('/vault/path'))
await waitFor(() => {
expect(result.current.state.status).toBe('ready')
})
})
})

View File

@@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
type OnboardingState =
| { status: 'loading' }
| { status: 'welcome'; defaultPath: string }
| { status: 'vault-missing'; vaultPath: string; defaultPath: string }
| { status: 'ready'; vaultPath: string }
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
const DISMISSED_KEY = 'laputa_welcome_dismissed'
function wasDismissed(): boolean {
try {
return localStorage.getItem(DISMISSED_KEY) === '1'
} catch {
return false
}
}
function markDismissed(): void {
try {
localStorage.setItem(DISMISSED_KEY, '1')
} catch {
// localStorage may be unavailable in some contexts
}
}
export function useOnboarding(initialVaultPath: string) {
const [state, setState] = useState<OnboardingState>({ status: 'loading' })
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function check() {
try {
const defaultPath = await tauriCall<string>('get_default_vault_path', {})
const exists = await tauriCall<boolean>('check_vault_exists', { path: initialVaultPath })
if (cancelled) return
if (exists) {
setState({ status: 'ready', vaultPath: initialVaultPath })
} else if (wasDismissed()) {
// User previously dismissed — show vault-missing instead of welcome
setState({ status: 'vault-missing', vaultPath: initialVaultPath, defaultPath })
} else {
setState({ status: 'welcome', defaultPath })
}
} catch {
// If commands fail (e.g. mock mode), just proceed
if (!cancelled) setState({ status: 'ready', vaultPath: initialVaultPath })
}
}
check()
return () => { cancelled = true }
}, [initialVaultPath])
const handleCreateVault = useCallback(async () => {
setCreating(true)
setError(null)
try {
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath: null })
markDismissed()
setState({ status: 'ready', vaultPath })
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
} finally {
setCreating(false)
}
}, [])
const handleOpenFolder = useCallback(async () => {
try {
const path = await pickFolder('Open vault folder')
if (!path) return
markDismissed()
setState({ status: 'ready', vaultPath: path })
} catch (err) {
setError(`Failed to open folder: ${err}`)
}
}, [])
const handleDismiss = useCallback(() => {
markDismissed()
setState({ status: 'ready', vaultPath: initialVaultPath })
}, [initialVaultPath])
return { state, creating, error, handleCreateVault, handleOpenFolder, handleDismiss }
}

View File

@@ -233,6 +233,47 @@ describe('useVaultLoader', () => {
expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new')
})
it('returns unsaved for paths in unsavedPaths', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry, '# Draft')
result.current.trackUnsaved('/vault/note/draft.md')
})
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
})
it('unsaved has higher priority than new', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry, '# Draft')
result.current.trackUnsaved('/vault/note/draft.md')
})
// addEntry also calls trackNew, so path is in both newPaths and unsavedPaths
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
})
it('clearUnsaved transitions from unsaved to new', async () => {
const { result } = await renderVaultLoader()
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
act(() => {
result.current.addEntry(newEntry, '# Draft')
result.current.trackUnsaved('/vault/note/draft.md')
})
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('unsaved')
act(() => { result.current.clearUnsaved('/vault/note/draft.md') })
expect(result.current.getNoteStatus('/vault/note/draft.md')).toBe('new')
})
it('treats untracked files as new (green dot, not orange)', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
@@ -419,4 +460,16 @@ describe('resolveNoteStatus', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], emptyPending)).toBe('modified')
expect(resolveNoteStatus('/vault/x.md', new Set(), [], emptyPending)).toBe('clean')
})
it('unsaved takes priority over all other statuses', () => {
const unsaved = new Set(['/vault/x.md'])
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], undefined, unsaved)).toBe('unsaved')
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')], undefined, unsaved)).toBe('unsaved')
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [], new Set(['/vault/x.md']), unsaved)).toBe('unsaved')
})
it('without unsavedPaths parameter, behavior is unchanged', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(['/vault/x.md']), [])).toBe('new')
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'modified')])).toBe('modified')
})
})

View File

@@ -42,6 +42,26 @@ function useNewNoteTracker() {
return { newPaths, trackNew, clear }
}
function useUnsavedTracker() {
const [unsavedPaths, setUnsavedPaths] = useState<Set<string>>(new Set())
const trackUnsaved = useCallback((path: string) => {
setUnsavedPaths((prev) => new Set(prev).add(path))
}, [])
const clearUnsaved = useCallback((path: string) => {
setUnsavedPaths((prev) => {
const next = new Set(prev)
next.delete(path)
return next
})
}, [])
const clearAll = useCallback(() => setUnsavedPaths(new Set()), [])
return { unsavedPaths, trackUnsaved, clearUnsaved, clearAll }
}
function usePendingSaveTracker() {
const [pendingSavePaths, setPendingSavePaths] = useState<Set<string>>(new Set())
@@ -61,8 +81,9 @@ function usePendingSaveTracker() {
}
export function resolveNoteStatus(
path: string, newPaths: Set<string>, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set<string>,
path: string, newPaths: Set<string>, modifiedFiles: ModifiedFile[], pendingSavePaths?: Set<string>, unsavedPaths?: Set<string>,
): NoteStatus {
if (unsavedPaths?.has(path)) return 'unsaved'
if (pendingSavePaths?.has(path)) return 'pendingSave'
if (newPaths.has(path)) return 'new'
const gitEntry = modifiedFiles.find((f) => f.path === path)
@@ -78,10 +99,11 @@ export function useVaultLoader(vaultPath: string) {
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
const tracker = useNewNoteTracker()
const pendingSave = usePendingSaveTracker()
const unsaved = useUnsavedTracker()
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear()
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear(); unsaved.clearAll()
loadVaultData(vaultPath)
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
.catch((err) => console.warn('Vault scan failed:', err))
@@ -139,7 +161,7 @@ export function useVaultLoader(vaultPath: string) {
tauriCall<string>('get_file_diff', { vaultPath, path }, { path }), [vaultPath])
const getNoteStatus = useCallback((path: string): NoteStatus =>
resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths])
resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths])
const commitAndPush = useCallback((message: string): Promise<string> =>
commitWithPush(vaultPath, message), [vaultPath])
@@ -158,5 +180,8 @@ export function useVaultLoader(vaultPath: string) {
getNoteStatus, commitAndPush, reloadVault,
addPendingSave: pendingSave.addPendingSave,
removePendingSave: pendingSave.removePendingSave,
unsavedPaths: unsaved.unsavedPaths,
trackUnsaved: unsaved.trackUnsaved,
clearUnsaved: unsaved.clearUnsaved,
}
}

113
src/hooks/useZoom.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useZoom } from './useZoom'
// Mock localStorage (jsdom's may be incomplete)
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { store = {} }),
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
describe('useZoom', () => {
beforeEach(() => {
localStorageMock.clear()
document.documentElement.style.removeProperty('zoom')
})
it('initializes at 100% by default', () => {
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('restores persisted zoom level from localStorage', () => {
localStorageMock.setItem('laputa:zoom-level', '120')
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(120)
})
it('ignores invalid persisted values', () => {
localStorageMock.setItem('laputa:zoom-level', 'banana')
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('ignores out-of-range persisted values', () => {
localStorageMock.setItem('laputa:zoom-level', '200')
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('zoomIn increases level by 10', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(110)
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('110')
})
it('zoomOut decreases level by 10', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomOut())
expect(result.current.zoomLevel).toBe(90)
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('90')
})
it('zoomIn clamps at 150', () => {
localStorageMock.setItem('laputa:zoom-level', '150')
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(150)
})
it('zoomOut clamps at 80', () => {
localStorageMock.setItem('laputa:zoom-level', '80')
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomOut())
expect(result.current.zoomLevel).toBe(80)
})
it('zoomReset returns to 100', () => {
localStorageMock.setItem('laputa:zoom-level', '130')
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomReset())
expect(result.current.zoomLevel).toBe(100)
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('100')
})
it('applies CSS zoom property to document element', () => {
const spy = vi.spyOn(document.documentElement.style, 'setProperty')
const { result } = renderHook(() => useZoom())
spy.mockClear() // clear the mount call
act(() => result.current.zoomIn())
expect(spy).toHaveBeenCalledWith('zoom', '110%')
spy.mockRestore()
})
it('zoomIn and zoomOut are stable callbacks', () => {
const { result, rerender } = renderHook(() => useZoom())
const { zoomIn: a, zoomOut: b, zoomReset: c } = result.current
rerender()
expect(result.current.zoomIn).toBe(a)
expect(result.current.zoomOut).toBe(b)
expect(result.current.zoomReset).toBe(c)
})
it('successive zoomIn calls accumulate', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
act(() => result.current.zoomIn())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(130)
})
it('handles localStorage getItem throwing', () => {
localStorageMock.getItem.mockImplementationOnce(() => { throw new Error('no storage') })
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
})

61
src/hooks/useZoom.ts Normal file
View File

@@ -0,0 +1,61 @@
import { useState, useEffect, useCallback } from 'react'
const ZOOM_KEY = 'laputa:zoom-level'
const MIN_ZOOM = 80
const MAX_ZOOM = 150
const STEP = 10
const DEFAULT_ZOOM = 100
function loadPersistedZoom(): number {
try {
const stored = localStorage.getItem(ZOOM_KEY)
if (stored !== null) {
const val = Number(stored)
if (val >= MIN_ZOOM && val <= MAX_ZOOM && val % STEP === 0) return val
}
} catch { /* localStorage unavailable */ }
return DEFAULT_ZOOM
}
function applyZoomToDocument(level: number): void {
document.documentElement.style.setProperty('zoom', `${level}%`)
}
function persistZoom(level: number): void {
try { localStorage.setItem(ZOOM_KEY, String(level)) } catch { /* ignore */ }
}
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 zoomIn = useCallback(() => {
setZoomLevel(prev => {
const next = Math.min(MAX_ZOOM, prev + STEP)
applyZoomToDocument(next)
persistZoom(next)
return next
})
}, [])
const zoomOut = useCallback(() => {
setZoomLevel(prev => {
const next = Math.max(MIN_ZOOM, prev - STEP)
applyZoomToDocument(next)
persistZoom(next)
return next
})
}, [])
const zoomReset = useCallback(() => {
setZoomLevel(DEFAULT_ZOOM)
applyZoomToDocument(DEFAULT_ZOOM)
persistZoom(DEFAULT_ZOOM)
}, [])
return { zoomLevel, zoomIn, zoomOut, zoomReset }
}

View File

@@ -164,3 +164,8 @@
letter-spacing: 0.02em;
text-transform: uppercase;
}
.referenced-by-panel button:hover {
text-decoration: underline;
text-decoration-style: dotted;
}

View File

@@ -77,8 +77,8 @@ let mockSettings: Settings = {
anthropic_key: null,
openai_key: null,
google_key: null,
github_token: 'gho_mock_token_for_testing',
github_username: 'lucaong',
github_token: null,
github_username: null,
auto_pull_interval_minutes: 5,
}
@@ -228,6 +228,13 @@ export const mockHandlers: Record<string, (args: any) => any> = {
}))
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
},
get_default_vault_path: () => '/Users/mock/Documents/Laputa',
check_vault_exists: (args: { path: string }) => {
// In mock mode, the demo-vault-v2 path always "exists"
return args.path.includes('demo-vault-v2')
},
create_getting_started_vault: () => '/Users/mock/Documents/Laputa',
register_mcp_tools: () => 'registered',
}
export function addMockEntry(_entry: VaultEntry, content: string): void {

View File

@@ -29,7 +29,7 @@ export interface VaultEntry {
outgoingLinks: string[]
}
export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave'
export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' | 'unsaved'
export interface GitCommit {
hash: string

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