Compare commits

...

45 Commits

Author SHA1 Message Date
Test
9960fd6139 fix: reassign AI panel shortcut from Cmd+I to Cmd+Option+I
Cmd+I is the standard italic shortcut in text editors. The AI panel
was overriding it, breaking italic formatting. Reassigned to
Cmd+Option+I (CmdOrCtrl+Alt+I in Tauri menu) which is free.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:28:04 +02:00
Test
e3923fe123 feat: pre-assign type when creating note from type section
When the user clicks "+" in the note list while a type section is
selected (e.g. Projects), the new note now gets that type pre-set in
its frontmatter. Previously, all notes were created as generic "Note"
type regardless of the selected section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:06:40 +02:00
Test
375baa4e1a fix: use percentage heights instead of 100vh to fix status bar visibility in native app
With titleBarStyle: "Overlay" in Tauri, 100vh in WKWebView includes the
overlaid title bar area, making the app-shell taller than the visible
viewport. The 30px StatusBar (including the Commit button) was pushed
below the visible area and clipped by overflow:hidden. Using height:100%
chains from html→body→#root→app-shell to correctly constrain to the
visible viewport.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:45:10 +02:00
Test
321a518a1f fix: reduce editor minimum width and padding at narrow sizes
Halve the editor min-width from 800px to 400px so the window can be
resized narrower. Add a container query that reduces horizontal padding
from 40px to 16px when the editor panel is under 600px wide, keeping
content readable without wasting space on padding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 02:29:05 +02:00
Test
ed549a160c fix: make commit button always visible in status bar
The commit button was inside ChangesBadge which returned null when
modifiedCount was 0, hiding the button entirely. Extract it into a
standalone CommitButton component that is always visible in the
status bar with a "Commit" text label for discoverability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:17:43 +02:00
Test
3de211df96 fix: rustfmt formatting for fallback YAML parser
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:42:45 +02:00
Test
f84faac7c3 fix: vault scanner fallback parser for malformed YAML frontmatter
Files with malformed YAML (e.g. Notion exports containing unescaped
double quotes inside double-quoted strings) caused gray_matter to return
Pod::Null instead of a parsed Hash. This silently reset all frontmatter
fields to defaults, making trashed/archived notes appear in Inbox.

Add a line-by-line fallback parser that extracts simple key:value pairs
when gray_matter fails, so critical fields (Trashed, Archived, type)
are never lost. Bump cache version to 9 to force rescan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:40:25 +02:00
Test
ee69e30b6b fix: remove overflow:hidden from status bar to prevent commit button clipping
The commit button inside ChangesBadge was being clipped by overflow:hidden
on the left status bar div when many badges were visible or at high zoom.
Removing the overflow constraint ensures the commit button is always visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:04:04 +02:00
Test
e823243a3b fix: prevent BlockNote from altering list markers and inserting HTML entities
BlockNote's serializer outputs `*` for bullet lists and inserts `&#x20;`
HTML entities around inline code within bold text. Both cause meaningless
git diffs. Fix by post-processing in compactMarkdown:
- Normalize `*` bullet markers to `-` (standard convention)
- Decode HTML entities like `&#x20;` back to literal characters
- Skip normalization inside fenced code blocks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:49:28 +02:00
Test
85e8eb7041 fix: rustfmt formatting for create_vault_folder
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:33:13 +02:00
Test
6640e74a74 fix: address folder tree QA feedback — recursive filtering, folder creation, path display, remove flatten banner
1. Folder selection now recursively includes notes from subfolders
2. + button creates a new folder with inline rename (Tauri command + UI)
3. Note list items show full path when note is in a subdirectory
4. Remove flat vault migration banner and all related code (Rust commands,
   hook, component, smoke test) — subfolders are now first-class

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:29:24 +02:00
Test
be98fd51e5 fix: vault selection dropdown hidden behind sidebar
Add position: relative and zIndex: 10 to the StatusBar footer to
establish a proper stacking context, so the vault menu dropdown
(zIndex: 1000) renders above the sidebar instead of behind it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:48:58 +02:00
Test
b1aaae82df chore: update ui-design.pen 2026-04-01 11:14:57 +02:00
Test
2f658425df chore: fix CodeScene ratchet thresholds to match remote API scores
Remote CodeScene scores (9.60/9.37) were below over-ratcheted thresholds
(9.84/9.38). Floor thresholds to actual remote values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:48:01 +02:00
Test
5ce1431522 fix: prevent infinite render loop when creating notes
updateEntry's .map() always returned a new array even when no entry matched,
causing unnecessary state changes. During note creation, addEntry uses
startTransition (deferred) while markContentPending calls updateEntry
synchronously — the entry doesn't exist yet, so the no-op .map() produced a
new reference that cascaded into "Maximum update depth exceeded" (which
surfaced as React error #185 in the production WKWebView build).

The fix makes updateEntry bail out (return prev) when no entry was changed,
preventing the spurious state update. Also removes the defensive try-catch
from the previous fix attempt and cleans up an unnecessary setToastMessage
dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:33:40 +02:00
Test
36febb75da fix: active section badge shows primary background instead of gray
The inline badgeStyle (background: var(--muted)) was overriding the
Tailwind activeBadgeClassName (bg-primary) because inline styles have
higher specificity. Fixed by not falling back to badgeStyle when
activeBadgeClassName is provided and the item is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:50:02 +02:00
Test
8a923a95cf fix: add error handling to createNoteImmediate to prevent crashes
Wraps the note creation flow in try-catch so errors in title
generation, template resolution, or tab opening are logged to
console instead of crashing the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:34:44 +02:00
Test
c0fed9c5c0 fix: wikilink autocomplete uses relative path, prevent silent rename
Bug 1: Wikilink autocomplete now always inserts the vault-relative path
as the target (e.g. [[docs/adr/0001-tauri-stack|Title]]) instead of
just the filename. This ensures wikilinks are unambiguous and resolve
correctly across subfolders after reload.

Bug 2: unique_dest_path now excludes the source file from collision
checks, preventing false positives where a note's own file is treated
as a naming conflict (causing silent -2 suffix renames).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:15:17 +02:00
Test
4d787d6f84 fix: eliminate scroll stutter and fix breadcrumb shadow consistency
Replace React state (useState + re-render) with direct DOM manipulation
for the breadcrumb title visibility. IntersectionObserver now toggles a
data-title-hidden attribute on the bar element, and CSS handles shadow
+ title visibility. This avoids re-rendering the heavy BlockNote editor
on every scroll intersection change.

Fixes: shadow always appears when title is hidden (CSS-driven, no race),
scroll is smooth (zero React re-renders during scroll).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:48:52 +02:00
Test
3bc824940a fix: prevent status bar overflow from hiding commit button
The left div in the status bar had no width constraint, causing items
at the end (commit button, Pulse) to overflow behind the right div
when the window is narrow or zoom is high.

Fixes:
- Add flex:1 + minWidth:0 + overflow:hidden to the left div
- Add flexShrink:0 to the right div so it stays visible
- Move ChangesBadge (with commit button) before SyncBadge so it
  appears earlier and is less likely to be clipped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:30:10 +02:00
Test
cebeca678f fix: use floor instead of round in CodeScene ratchet
round(9.8457, 2) → 9.85 which exceeds the actual score, causing
the threshold to be unreachable. Use math.floor to truncate instead:
9.8457 → 9.84, 9.3884 → 9.38.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:01:03 +02:00
Test
dd59ee072d chore: ratchet CodeScene thresholds to 9.85/9.39 2026-03-31 16:59:41 +02:00
Test
828d5f84a9 chore: round down CodeScene thresholds to match actual scores
Actual scores are 9.8457/9.3884 — previous thresholds 9.85/9.39
were above the actual values due to rounding up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:44:44 +02:00
Test
459ee8c7e3 fix: normalize property row and chip heights for consistent layout
All chips (type, status, date, tags, boolean, color, text, URL) now use
h-6 (24px) with inline-flex + items-center, and PropertyRow has min-h-7
(28px) so every row aligns regardless of property type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:30:22 +02:00
Test
62e1dbb173 chore: ratchet CodeScene thresholds to 9.85/9.39 2026-03-31 14:28:14 +02:00
Test
f15dc0e516 chore: fix ratchet thresholds — round down to match actual scores
The auto-ratchet rounded 9.845→9.85 and 9.388→9.39, creating thresholds
higher than the actual scores. Fix by rounding down.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:13:53 +02:00
Test
491e5d3962 style: cargo fmt — fix pre-existing Rust formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:54:18 +02:00
Test
1199840fdc test: add Playwright + Vitest tests for raw editor type propagation
Verifies that editing the type field in the raw editor (Cmd+\) immediately
updates the Properties panel type selector without navigation or reload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:51:48 +02:00
Test
39db25a39a fix: propagate frontmatter changes from raw editor to vault entries
When editing type/status/color/icon in the raw editor (Cmd+\), changes
now immediately flow into the reactive VaultEntry state, updating the
Properties panel, note list, and sidebar without save/reload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:32:55 +02:00
Test
213e51c135 docs: task not done until git push succeeds — fix pre-push failures before marking done 2026-03-31 12:27:53 +02:00
Test
9a253392e5 fix: use next_back() instead of last() on DoubleEndedIterator (clippy) 2026-03-31 12:23:28 +02:00
Test
c4001ec3f6 feat: detect external file renames and offer wikilink update via banner
When the app regains focus, checks git diff for renames (--diff-filter=R).
If renamed .md files are found, shows a non-blocking banner with "Update
wikilinks" and "Ignore" buttons. The update reuses the existing vault-wide
wikilink replacement logic from rename.rs.

New Tauri commands: detect_renames, update_wikilinks_for_renames.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:58:32 +02:00
Test
e3e60a2815 feat: subfolder support — path-based wikilink resolution and cross-folder backlinks
- resolveEntry: add path-suffix matching so [[docs/adr/0031-foo]] resolves
  to entries in subdirectories (Pass 1, before filename match)
- Inspector backlinks: replace hardcoded /Laputa/ regex with generic
  path-suffix matching via targetMatchesEntry helper
- Autocomplete preFilter: also match against file path so searching
  subfolder names (e.g. "adr") surfaces nested notes
- Add relativePathStem utility for vault-relative path extraction

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:47:54 +02:00
Test
e43e2a7549 feat: move filter chips to bottom of note list with gradient fade
Move FilterPills and InboxFilterPills from the top (below header) to
a floating position at the bottom of the note list. When position is
"bottom", pills are absolutely positioned with a gradient background
(transparent → card color) to create a "floating above content"
effect. Pills are centered with gap-2 and wrap to multiple lines.
Matches the ui-design.pen "Filter Pills Bar" frame.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:38:13 +02:00
Test
517f1c04f5 fix: remove duplicate invoke import in App.tsx
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:32:23 +02:00
Test
635d793d32 feat: show blocking modal when vault has no git repo, offer auto-init
When opening a vault without a .git directory, a blocking modal prevents
app use until the user either creates a repository (git init + add +
commit) or chooses another vault. The check runs via is_git_repo Tauri
command; browser mode fails open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:30:39 +02:00
Test
093f1bc9dc test: add folder tree and filtering tests; docs: ADR-0033
Add FolderTree component tests (render, expand, collapse, select,
highlight) and folder filtering tests in noteListHelpers (path
matching, sibling exclusion, archive/trash filtering).

ADR-0033 documents the decision to scan all subdirectories and
expose folder tree navigation, superseding ADR-0006's scanning
constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:19:31 +02:00
Test
7dc7897367 feat: add FOLDERS section to sidebar with collapsible tree
Build FolderTree component that renders the vault's directory
structure below the TYPES sections. Integrates with SidebarSelection
to filter the note list by folder path. Styled to match the
ui-design.pen "Folder Tree Sidebar" frame with Phosphor folder
icons, blue highlight for selected folder, and indented children
with guide lines. Wire folder data from useVaultLoader → App → Sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:14:50 +02:00
Test
46a08c6f43 feat: show Initialize/Invalid properties prompts for notes without frontmatter
Properties panel now detects frontmatter state (valid/empty/none/invalid):
- No frontmatter: shows "Initialize properties" button that adds type+title
- Invalid YAML: shows warning with "Fix in editor" button (toggles raw mode)
- Valid frontmatter: unchanged behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:07:39 +02:00
Test
eb7a45adf9 feat: scan subdirectories and expose folder tree for sidebar
Extend the Rust vault scanner to index .md files in all visible
subdirectories (hidden dirs excluded). Add FolderNode struct and
list_vault_folders Tauri command that returns the directory tree.
On the frontend, add FolderNode type, extend SidebarSelection with
{ kind: 'folder'; path: string }, and add isInFolder() filtering
in noteListHelpers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:06:11 +02:00
Test
e89dc65c22 docs: task-done notification is informational only — no Brian approval needed 2026-03-31 11:00:04 +02:00
Test
ce4736b619 fix: disable Tauri native drag-drop to restore BlockNote block dragging
Tauri's default dragDropEnabled=true intercepts HTML5 DnD events at the
webview level, preventing BlockNote's block handle drag-to-reorder from
receiving dragstart/dragover/drop events. Setting dragDropEnabled=false
lets all drag events flow through standard HTML5 DnD, which BlockNote
expects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:51:32 +02:00
Test
7d94bb26bb feat: show note title in breadcrumb bar when scrolled past title
Use IntersectionObserver on the title section to detect when it
scrolls out of view. When hidden, the breadcrumb bar morphs to
display "<type> › <emoji> <title>" on the left and gains a subtle
shadow to separate it from the content below.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:42:38 +02:00
Test
b78e42272e feat: add markdown syntax highlighting in raw editor
Wire up @codemirror/lang-markdown with a custom HighlightStyle to
highlight headings, bold, italic, strikethrough, links, lists,
blockquotes, and inline code in the raw CodeMirror editor. The custom
frontmatter plugin is kept for YAML highlighting; its heading
decoration is removed in favour of the language-based parser.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:30:44 +02:00
Test
4d0e7469b9 feat: use JetBrains Mono for the raw editor
Load JetBrains Mono (regular weight) from Google Fonts and set it as the
primary font for the CodeMirror raw editor, replacing Berkeley Mono.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:27:27 +02:00
97 changed files with 8959 additions and 26599 deletions

View File

@@ -2,7 +2,13 @@
Mark a Laputa task as done: add completion comment, move to In Review, notify Brian, then self-dispatch the next task.
Run this after Phase 1 (Playwright) and Phase 2 (native app QA) both pass.
Run this after Phase 1 (Playwright) and Phase 2 (native app QA) both pass **and `git push origin main` has succeeded**.
⚠️ A task is NOT done until the push succeeds. If the push is blocked by the pre-push hook (clippy, tests, CodeScene, build):
- Read the error
- Fix it (never use `--no-verify`)
- Commit the fix and push again
- Repeat until push exits with code 0
## Steps
@@ -29,12 +35,14 @@ curl -s -X POST "https://api.todoist.com/api/v1/tasks/$ARGUMENTS/move" \
-d '{"section_id": "6g3XjX33FF4Vj86M"}'
```
**3. Notify Brian**
**3. Notify Luca (informational only — no action needed from him)**
```bash
openclaw system event --text "laputa-task-done:$ARGUMENTS" --mode now
```
This is a passive notification. Luca reviews tasks in his own time. Do NOT wait for approval — proceed immediately to step 4.
**4. Pick the next task**
Run `/laputa-next-task` to get the next task and start working on it immediately.

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.85
AVERAGE_THRESHOLD=9.39
HOTSPOT_THRESHOLD=9.6
AVERAGE_THRESHOLD=9.37

View File

@@ -162,9 +162,11 @@ if failed:
sys.exit(1)
# Ratchet: update thresholds file if scores improved
# Truncate (floor) to 2 decimals so the threshold never exceeds the actual score
import math
thresholds_file = '$THRESHOLDS_FILE'
new_hotspot = max(hotspot_min, round(hotspot, 2))
new_average = max(average_min, round(average, 2))
new_hotspot = max(hotspot_min, math.floor(hotspot * 100) / 100)
new_average = max(average_min, math.floor(average * 100) / 100)
if new_hotspot > hotspot_min or new_average > average_min:
with open(thresholds_file, 'w') as f:
f.write(f'HOTSPOT_THRESHOLD={new_hotspot}\nAVERAGE_THRESHOLD={new_average}\n')

View File

@@ -55,6 +55,7 @@ After both phases pass, run `/laputa-done <task_id>` → moves to In Review, not
- Push directly to `main` — no PRs, no branches
- Pre-push hook runs full check suite (build + tests + Playwright + CodeScene)
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. Never use `--no-verify`.
- **⛔ NEVER use --no-verify**
### TDD (mandatory)

View File

@@ -1,10 +1,11 @@
---
type: ADR
id: "0032"
title: "Git actions (Changes, Pulse, Commit) in status bar, not sidebar"
title: 0032 Status Bar For Git Actions
status: active
date: 2026-03-31
---
[[Subfolder scanning and folder tree navigation]]
## Context
@@ -16,14 +17,14 @@ The Laputa sidebar originally surfaced git-related affordances — a "Changes" n
## Options considered
- **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan.
- **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom.
- **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected.
* **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan.
* **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom.
* **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected.
## Consequences
- Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only
- `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props
- Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended
- Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state
- Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar
* Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only
* `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props
* Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended
* Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state
* Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar

View File

@@ -0,0 +1,34 @@
---
type: ADR
id: "0033"
title: "Subfolder scanning and folder tree navigation"
status: active
date: 2026-03-31
---
## Context
[[0032 Status Bar For Git Actions]]
Supersedes the scanning constraint in [ADR-0006](0006-flat-vault-structure.md) which limited vault indexing to root-level `.md` files plus protected folders (`attachments/`, `assets/`).
Users with folder-based workflows (PARA, Zettelkasten with folders, project directories) could not see or filter notes by directory. The vault scanner silently ignored all subdirectory `.md` files, making Laputa unsuitable for vaults with any folder structure.
## Decision
**Extend the Rust vault scanner to index&#x20;**`.md`**&#x20;files in all visible subdirectories, and expose the vault's folder tree via a new&#x20;**`list_vault_folders`**&#x20;Tauri command so the sidebar can render a collapsible FOLDERS section.**
Hidden directories (names starting with `.`, plus `.git` and `.laputa`) are excluded from both scanning and the folder tree.
## Options considered
* **Option A** (chosen): Scan all subdirectories with `walkdir`, expose separate `list_vault_folders` command — simple, no schema changes to VaultEntry, folder tree is lightweight and independent of the entry cache.
* **Option B**: Add a `folder` field to VaultEntry and derive the tree on the frontend — couples folder metadata to the entry cache, complicates cache invalidation when folders are created/deleted without file changes.
* **Option C**: Keep flat scanning, add a "virtual folders" feature that groups by path prefix from frontmatter — doesn't solve the core problem of missing notes in subdirectories.
## Consequences
* All `.md` files in the vault are now indexed regardless of depth — vaults with many non-note `.md` files (e.g. node_modules) will see spurious entries. Mitigation: hidden directories are already excluded; users can add a `.laputaignore` in the future if needed.
* The git-based cache in `cache.rs` already uses `walkdir` for change detection, so this change aligns scanning with caching.
* `SidebarSelection` gains a new `{ kind: 'folder'; path: string }` variant — all exhaustive switches on selection kind must handle it.
* ADR-0006's "flat vault" principle is relaxed: notes can now live in subdirectories. Type definitions still live in `type/` at the root.
* Re-evaluate if users request recursive folder filtering (currently only direct children are shown when a folder is selected).

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0034"
title: "Git repo required — blocking modal enforces vault prerequisite"
status: active
date: 2026-04-01
---
## Context
ADR-0014 (git-based vault cache) and ADR-0021 (push-to-main workflow) both assume the vault is a git repository, but neither codified it as a hard enforcement. In practice, opening a non-git folder silently degraded: the cache couldn't compute a commit hash, Pulse/Changes were empty, and commit/push commands failed. The failure mode was invisible to users.
## Decision
**When the app opens a vault that has no `.git` directory, a blocking modal prevents all app use until the user either initialises a git repository (git init + initial commit, offered as a one-click action) or selects a different vault. The check is performed by a new `is_git_repo` Tauri command. In browser/dev mode, the check fails open (modal is skipped).**
## Options considered
- **Option A** (chosen): Hard block via modal on vault open — unambiguous, prevents silent failures, surfaces the fix immediately. Downside: breaks existing workflows for users with non-git vaults; requires a clear escape hatch (choose different vault).
- **Option B**: Soft warning banner, allow using the app without git — avoids blocking users, but silent failures persist for Pulse/Changes/commit features.
- **Option C**: Auto-init git on vault open without asking — less friction, but surprising; user may not want their vault in git.
## Consequences
- Git is now a first-class prerequisite for Laputa vaults, not just implied by the cache strategy.
- The `is_git_repo` command is intentionally lightweight (checks for `.git` existence only; does not validate remote or commit history).
- The modal offers `git init` + an initial commit as a one-click path, lowering the barrier for new users.
- Browser mode bypasses the check so dev/Storybook workflows are unaffected.
- Re-evaluate if Laputa needs to support non-git vaults (e.g., iCloud-only, shared network drive); at that point ADR-0014 would also need revisiting.

View File

@@ -0,0 +1,31 @@
---
type: ADR
id: "0035"
title: "Path-suffix wikilink resolution for subfolder vaults"
status: active
date: 2026-04-01
---
## Context
ADR-0006 stated that wikilink resolution was "simplified to multi-pass title/filename matching — no path-based matching needed" because the vault was flat. ADR-0033 relaxed the flat-vault constraint by adding subfolder scanning. As a result, wikilinks like `[[docs/adr/0031-foo]]` or `[[adr/0031-foo]]` could not resolve to entries in subdirectories: the resolver only matched on `title` and `filename` stem, never on the vault-relative path.
The backlink detection in the Inspector also used a hardcoded `/Laputa/` path regex, which was wrong for any vault that isn't named "Laputa".
## Decision
**Add path-suffix matching as Pass 1 of wikilink resolution: a link target resolves to a `VaultEntry` if the entry's vault-relative path ends with the link string (with or without `.md`). Filename-stem matching (the previous Pass 1) becomes Pass 2. Inspector backlinks replace the hardcoded `/Laputa/` regex with a generic `targetMatchesEntry` path-suffix helper. Autocomplete pre-filter also matches against the full vault-relative path so subfolder names surface results.**
## Options considered
- **Option A** (chosen): Path-suffix as Pass 1, then filename match as Pass 2 — consistent with how Obsidian resolves links in multi-folder vaults, zero config. Downside: if two notes share the same filename in different folders, only the first (path-suffix) match wins.
- **Option B**: Strict full-path matching only (disable title-stem resolution) — unambiguous, but breaks the majority of existing short-form `[[note-title]]` links.
- **Option C**: Keep title-only matching, require full paths for subfolder notes — backwards-compatible, but forces users to always type full paths for subfolders, defeating the purpose of wikilinks.
## Consequences
- Supersedes the "no path-based matching needed" clause from ADR-0006 (that assumption was contingent on the flat vault invariant, which ADR-0033 relaxed).
- `relativePathStem` utility added in `wikilink.ts` to extract the vault-relative path stem from a full `VaultEntry`.
- The Inspector's `targetMatchesEntry` helper is now the canonical way to test if a link resolves to an entry — use it everywhere instead of ad-hoc regex.
- Wikilink autocomplete suggestions now surface notes in subfolders when users type a folder prefix (e.g. `[[adr/`).
- Re-evaluate if path-suffix ambiguity (two files with the same name in different folders) becomes a user complaint.

View File

@@ -0,0 +1,31 @@
---
type: ADR
id: "0036"
title: "External rename detection via git diff on focus regain"
status: active
date: 2026-04-01
---
## Context
Laputa handles in-app renames (rename.rs) and propagates wikilink updates across the vault. But notes can also be renamed externally — from Finder, another editor, or a git operation (e.g., `git mv`). In those cases, the app had no way to detect that a rename had occurred, leaving wikilinks broken and the vault inconsistent.
The app already uses git for the cache (ADR-0014) and requires git as a vault prerequisite (ADR-0034), making git diff a natural and already-available detection mechanism.
## Decision
**When the app window regains focus, run `git diff --diff-filter=R --name-status HEAD` to detect file renames that occurred since the last committed HEAD. If any renamed `.md` files are found, show a non-blocking banner ("X file(s) renamed — update wikilinks?"). Accepting triggers the existing vault-wide wikilink replacement logic (reused from rename.rs). Ignoring dismisses the banner without changes. New Tauri commands: `detect_renames` and `update_wikilinks_for_renames`.**
## Options considered
- **Option A** (chosen): Git diff on focus regain, non-blocking banner — uses existing infrastructure, non-disruptive, user retains control. Downside: only detects renames that are staged/committed; uncommitted renames via `git mv` are captured, but renames done purely in Finder (no git involvement) are not.
- **Option B**: `FSEvents` / file-system watcher for rename events — catches all renames regardless of git. Downside: significantly more complex, requires Rust async machinery, false positives from editor temp files, and this feature is already planned as a separate enhancement.
- **Option C**: Scan for broken wikilinks on focus — correct but O(n) and noisy; doesn't tell us the new filename.
## Consequences
- Git's rename detection (`--diff-filter=R`) requires the rename to be git-tracked (either staged or committed); renames that happen outside git knowledge are not detected by this mechanism.
- The on-focus check runs `git diff HEAD` which is fast but adds a small shell invocation overhead each time the window activates. This is acceptable for typical vault sizes.
- `rename.rs` is now shared between in-app renames and external rename recovery — the replacement logic is the canonical entry point for wikilink bulk updates.
- The banner is non-blocking and "Ignore" is always available — the user never loses work.
- Re-evaluate if FS-level rename detection (outside git) becomes a priority; at that point this mechanism would be a fallback, not the primary strategy.

View File

@@ -0,0 +1,31 @@
---
type: ADR
id: "0037"
title: "Language-based markdown syntax highlighting in raw editor"
status: active
date: 2026-04-01
---
## Context
The raw editor (CodeMirror 6, introduced in ADR-0022) initially had a custom `frontmatterHighlight` extension that used regex-based decoration for YAML frontmatter and headings. Markdown body content had no syntax highlighting at all, making the raw editor feel like a plain textarea despite being a full CodeMirror instance.
Extending the custom regex-based approach to cover all markdown syntax (bold, italic, links, lists, blockquotes, code) would have been brittle and hard to maintain.
## Decision
**Replace the custom heading decoration in `frontmatterHighlight.ts` with `@codemirror/lang-markdown` (the official CodeMirror language package). A custom `HighlightStyle` maps CodeMirror highlight tags to visual styles for headings, bold, italic, strikethrough, links, lists, blockquotes, and inline code. The frontmatter YAML plugin is retained for YAML-specific colouring but its heading decoration is removed in favour of the language parser.**
## Options considered
- **Option A** (chosen): `@codemirror/lang-markdown` with custom HighlightStyle — uses the official, maintained language parser; future highlight rules are one CSS declaration. Downside: adds a new npm dependency; the custom frontmatter plugin must be kept separately.
- **Option B**: Extend the custom regex plugin to cover all markdown — no new dependency. Downside: regex-based tokenisation is fragile (e.g., nested formatting), already proving hard to maintain after the heading/frontmatter overlap bug.
- **Option C**: Switch to a markdown-aware editor (e.g., Milkdown, Monaco) — full-featured. Downside: major migration, breaks the dual-editor architecture in ADR-0022, significant scope.
## Consequences
- `@codemirror/lang-markdown` added to `package.json` — this is the only new runtime dependency introduced by this change.
- `frontmatterHighlight.ts` is simplified (heading decoration removed); `markdownHighlight.ts` is the new extension responsible for body highlighting.
- The two extensions are composed in `useCodeMirror.ts` — YAML frontmatter block is still styled by the custom plugin; everything else by the language parser.
- Future syntax highlighting changes (e.g., task lists, tables) can be added by extending the `HighlightStyle` without modifying the parser.
- Re-evaluate if `@codemirror/lang-markdown` conflicts with the custom frontmatter YAML handling as the editor evolves (e.g., if frontmatter block needs to be parsed as a code block rather than decorated text).

View File

@@ -88,3 +88,8 @@ proposed → active → superseded
| [0030](0030-rust-commands-module-split.md) | Rust commands/ module split by domain | active |
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active |
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active |
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |

View File

@@ -5,26 +5,27 @@ title: "Canary release channel and local feature flags"
status: active
date: 2026-03-25
---
## Context
Shipping new features directly to all users is risky. A mechanism was needed to let early adopters test pre-release builds and to gate experimental features behind flags that can be toggled without a new release.
[[Keyboard-first design principle]]
## Decision
**Add a canary release channel alongside stable, with builds from the `canary` branch. Feature flags are localStorage-based (`ff_<name>`) with compile-time defaults, checked via `useFeatureFlag(flag)` hook. The update channel is configurable in Settings.**
**Add a canary release channel alongside stable, with builds from the&#x20;**`canary`**&#x20;branch. Feature flags are localStorage-based (**`ff_<name>`**) with compile-time defaults, checked via&#x20;**`useFeatureFlag(flag)`**&#x20;hook. The update channel is configurable in Settings.**
## Options considered
- **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages.
- **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency.
- **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds.
* **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages.
* **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency.
* **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds.
## Consequences
- `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch.
- Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease.
- `useUpdater(channel)` checks the appropriate update manifest.
- `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union.
- `update_channel` stored in Settings as `"stable"` or `"canary"`.
- Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts.
* `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch.
* Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease.
* `useUpdater(channel)` checks the appropriate update manifest.
* `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union.
* `update_channel` stored in Settings as `"stable"` or `"canary"`.
* Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts.

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
<title>laputa-scaffold</title>
</head>
<body>

View File

@@ -32,6 +32,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@mantine/core": "^8.3.14",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-dialog": "^1.1.15",

3
pnpm-lock.yaml generated
View File

@@ -47,6 +47,9 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
'@lezer/highlight':
specifier: ^1.2.3
version: 1.2.3
'@mantine/core':
specifier: ^8.3.14
version: 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)

View File

@@ -128,6 +128,22 @@ pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, St
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub fn is_git_repo(vault_path: String) -> bool {
let vault_path = expand_tilde(&vault_path);
std::path::Path::new(vault_path.as_ref())
.join(".git")
.is_dir()
}
#[cfg(desktop)]
#[tauri::command]
pub fn init_git_repo(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
crate::git::init_repo(&vault_path)
}
// ── Git commands (mobile stubs) ─────────────────────────────────────────────
#[cfg(mobile)]
@@ -230,3 +246,15 @@ pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, S
behind: 0,
})
}
#[cfg(mobile)]
#[tauri::command]
pub fn is_git_repo(_vault_path: String) -> bool {
false
}
#[cfg(mobile)]
#[tauri::command]
pub fn init_git_repo(_vault_path: String) -> Result<(), String> {
Err("Git init is not available on mobile".into())
}

View File

@@ -1,6 +1,6 @@
use crate::frontmatter::FrontmatterValue;
use crate::search::SearchResponse;
use crate::vault::{RenameResult, VaultEntry};
use crate::vault::{DetectedRename, FolderNode, RenameResult, VaultEntry};
use crate::{frontmatter, git, search, vault};
use super::expand_tilde;
@@ -13,6 +13,12 @@ pub fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn list_vault_folders(path: String) -> Result<Vec<FolderNode>, String> {
let path = expand_tilde(&path);
vault::scan_vault_folders(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn get_note_content(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
@@ -37,6 +43,21 @@ pub fn rename_note(
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
}
#[tauri::command]
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
let vault_path = expand_tilde(&vault_path);
vault::detect_renames(&vault_path)
}
#[tauri::command]
pub fn update_wikilinks_for_renames(
vault_path: String,
renames: Vec<DetectedRename>,
) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::update_wikilinks_for_renames(&vault_path, &renames)
}
#[tauri::command]
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
@@ -68,15 +89,14 @@ pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
}
#[tauri::command]
pub fn flatten_vault(vault_path: String) -> Result<usize, String> {
pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::flatten_vault(&vault_path)
}
#[tauri::command]
pub fn vault_health_check(vault_path: String) -> Result<vault::VaultHealthReport, String> {
let vault_path = expand_tilde(&vault_path);
vault::vault_health_check(&vault_path)
let folder_path = std::path::Path::new(vault_path.as_ref()).join(&folder_name);
if folder_path.exists() {
return Err(format!("Folder '{}' already exists", folder_name));
}
std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name)
}
#[tauri::command]
@@ -206,7 +226,6 @@ pub async fn search_vault(
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)?;
vault::flatten_vault(&vault_path)?;
vault::repair_config_files(&vault_path)?;
git::ensure_gitignore(&vault_path)?;
Ok("Vault repaired".to_string())
@@ -341,7 +360,7 @@ mod tests {
}
#[test]
fn test_repair_vault_flattens_type_folders() {
fn test_repair_vault_migrates_is_a_to_type() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let note_dir = vault_path.join("note");
@@ -350,9 +369,8 @@ mod tests {
let result = repair_vault(vault_path.to_str().unwrap().to_string());
assert!(result.is_ok());
assert!(vault_path.join("hello.md").exists());
assert!(!note_dir.join("hello.md").exists());
let content = std::fs::read_to_string(vault_path.join("hello.md")).unwrap();
assert!(note_dir.join("hello.md").exists());
let content = std::fs::read_to_string(note_dir.join("hello.md")).unwrap();
assert!(content.contains("type: Note"));
assert!(!content.contains("is_a:"));
}

View File

@@ -117,11 +117,14 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
commands::list_vault,
commands::list_vault_folders,
commands::get_note_content,
commands::save_note_content,
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::detect_renames,
commands::update_wikilinks_for_renames,
commands::get_file_history,
commands::get_modified_files,
commands::get_file_diff,
@@ -137,6 +140,8 @@ pub fn run() {
commands::get_conflict_mode,
commands::git_resolve_conflict,
commands::git_commit_conflict_resolution,
commands::is_git_repo,
commands::init_git_repo,
commands::check_claude_cli,
commands::stream_claude_chat,
commands::stream_claude_agent,
@@ -150,8 +155,7 @@ pub fn run() {
commands::batch_delete_notes,
commands::empty_trash,
commands::migrate_is_a_to_type,
commands::flatten_vault,
commands::vault_health_check,
commands::create_vault_folder,
commands::batch_archive_notes,
commands::batch_trash_notes,
commands::get_settings,

View File

@@ -295,7 +295,7 @@ fn build_note_menu(app: &App) -> MenuResult {
.build(app)?;
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
.id(VIEW_TOGGLE_AI_CHAT)
.accelerator("CmdOrCtrl+I")
.accelerator("CmdOrCtrl+Alt+I")
.build(app)?;
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
.id(VIEW_TOGGLE_BACKLINKS)

View File

@@ -8,7 +8,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
// --- Vault Cache ---
/// Bump this when VaultEntry fields change to force a full rescan.
const CACHE_VERSION: u32 = 8;
const CACHE_VERSION: u32 = 9;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {

View File

@@ -1,6 +1,17 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// A node in the vault's folder tree. Only contains directories, not files.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FolderNode {
/// Folder name (last path component).
pub name: String,
/// Path relative to the vault root, using `/` separators (e.g. "projects/laputa").
pub path: String,
/// Child folders (sorted alphabetically).
pub children: Vec<FolderNode>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct VaultEntry {
pub path: String,

View File

@@ -284,20 +284,120 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
}
}
/// Strip matching outer quotes (single or double) from a YAML scalar.
fn unquote(s: &str) -> &str {
s.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.or_else(|| {
s.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
})
.unwrap_or(s)
}
/// Parse a scalar YAML value into a JSON value.
fn parse_scalar(s: &str) -> serde_json::Value {
let trimmed = unquote(s);
match trimmed.to_lowercase().as_str() {
"true" | "yes" => serde_json::Value::Bool(true),
"false" | "no" => serde_json::Value::Bool(false),
_ => trimmed
.parse::<i64>()
.map(|n| serde_json::json!(n))
.unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string())),
}
}
/// Return the key from a top-level `key:` or `"key":` YAML line.
/// Returns `None` for indented, blank, or non-key lines.
fn extract_yaml_key(line: &str) -> Option<&str> {
if line.is_empty() || line.starts_with(' ') || line.starts_with('\t') {
return None;
}
let (k, _) = line.split_once(':')?;
Some(k.trim().trim_matches('"'))
}
/// Flush a pending list accumulator into the map.
fn flush_list(
map: &mut HashMap<String, serde_json::Value>,
key: &mut Option<String>,
items: &mut Vec<serde_json::Value>,
) {
if let Some(k) = key.take() {
if !items.is_empty() {
map.insert(k, serde_json::Value::Array(std::mem::take(items)));
}
}
}
/// Fallback parser for when gray_matter fails to parse YAML (returns raw string).
/// Extracts simple `key: value` lines, handling booleans, numbers, quoted strings,
/// and YAML lists.
fn fallback_parse_yaml_string(raw: &str) -> HashMap<String, serde_json::Value> {
let mut map = HashMap::new();
let mut list_key: Option<String> = None;
let mut list_items: Vec<serde_json::Value> = Vec::new();
for line in raw.lines() {
// Accumulate list items under the current key
if list_key.is_some() {
if let Some(item) = line.strip_prefix(" - ") {
list_items.push(parse_scalar(item.trim()));
continue;
}
flush_list(&mut map, &mut list_key, &mut list_items);
}
let Some(key) = extract_yaml_key(line) else {
continue;
};
let value_part = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or("");
if value_part.is_empty() {
list_key = Some(key.to_string());
} else {
map.insert(key.to_string(), parse_scalar(value_part));
}
}
flush_list(&mut map, &mut list_key, &mut list_items);
map
}
/// Extract the raw YAML frontmatter string from between `---` delimiters.
fn extract_raw_frontmatter(content: &str) -> Option<&str> {
let rest = content.strip_prefix("---")?;
let rest = rest
.strip_prefix('\n')
.or_else(|| rest.strip_prefix("\r\n"))?;
let end = rest.find("\n---")?;
Some(&rest[..end])
}
/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data.
/// When gray_matter fails to parse YAML (e.g. malformed quotes from Notion exports),
/// `raw_content` is used as a fallback: simple key:value pairs are extracted line-by-line
/// so that critical fields like Trashed, Archived, type are not silently lost.
pub(crate) fn extract_fm_and_rels(
data: Option<gray_matter::Pod>,
raw_content: &str,
) -> (
Frontmatter,
HashMap<String, Vec<String>>,
HashMap<String, serde_json::Value>,
) {
let hash = match data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
let json_map = match data {
Some(gray_matter::Pod::Hash(map)) => {
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect()
}
_ => {
// gray_matter returned Null, String, or None — YAML parse failed.
// Fall back to line-by-line extraction from the raw frontmatter block.
match extract_raw_frontmatter(raw_content) {
Some(raw) => fallback_parse_yaml_string(raw),
None => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
}
}
};
let json_map: HashMap<String, serde_json::Value> =
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
(
parse_frontmatter(&json_map),
extract_relationships(&json_map),

View File

@@ -1,6 +1,3 @@
use regex::Regex;
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
@@ -118,311 +115,6 @@ pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
Ok(migrated)
}
/// Folders that are NOT flattened — they contain assets, not notes.
const KEEP_FOLDERS: &[&str] = &["attachments", "assets"];
/// Determine a unique filename at `dest_dir`, appending -2, -3, etc. on collision.
fn unique_filename(dest_dir: &Path, filename: &str, taken: &HashSet<String>) -> String {
if !dest_dir.join(filename).exists() && !taken.contains(filename) {
return filename.to_string();
}
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(filename)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
let mut counter = 2;
loop {
let candidate = format!("{}-{}{}", stem, counter, ext);
if !dest_dir.join(&candidate).exists() && !taken.contains(&candidate) {
return candidate;
}
counter += 1;
}
}
/// Flatten vault structure: move all notes from type-based subfolders to the vault root.
/// Skips `type/`, `config/`, `attachments/`, and `_themes/` folders.
/// Updates path-based wikilinks to title-based after moving.
/// Returns the number of files moved.
pub fn flatten_vault(vault_path: &str) -> Result<usize, String> {
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
// Collect all .md files in subfolders (not already at root, not in KEEP_FOLDERS)
let mut to_move: Vec<(std::path::PathBuf, String)> = Vec::new();
for entry in WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
// Skip files already at vault root
if path.parent() == Some(vault) {
continue;
}
// Check if this file is inside a KEEP_FOLDER
let rel = path.strip_prefix(vault).unwrap_or(path);
let top_folder = rel
.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.unwrap_or_default();
if KEEP_FOLDERS.iter().any(|&k| k == top_folder) {
continue;
}
// Hidden folders (e.g. .laputa, .git)
if top_folder.starts_with('.') {
continue;
}
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
to_move.push((path.to_path_buf(), filename));
}
if to_move.is_empty() {
return Ok(0);
}
// Build a map of old path → (new filename, old relative stem) for wikilink updates
let mut taken: HashSet<String> = HashSet::new();
// Pre-populate with files already at root
if let Ok(entries) = fs::read_dir(vault) {
for e in entries.flatten() {
if e.path().is_file() {
if let Some(name) = e.file_name().to_str() {
taken.insert(name.to_string());
}
}
}
}
let vault_prefix = format!("{}/", vault.to_string_lossy());
let mut moves: Vec<(std::path::PathBuf, std::path::PathBuf, String)> = Vec::new(); // (old, new, old_rel_stem)
for (old_path, filename) in &to_move {
let new_name = unique_filename(vault, filename, &taken);
taken.insert(new_name.clone());
let new_path = vault.join(&new_name);
let old_rel = old_path
.to_string_lossy()
.strip_prefix(&vault_prefix)
.unwrap_or(&old_path.to_string_lossy())
.strip_suffix(".md")
.unwrap_or(&old_path.to_string_lossy())
.to_string();
moves.push((old_path.clone(), new_path, old_rel));
}
// Move all files
let mut moved = 0;
for (old, new, _) in &moves {
match fs::rename(old, new) {
Ok(()) => moved += 1,
Err(e) => log::warn!(
"Failed to move {} → {}: {}",
old.display(),
new.display(),
e
),
}
}
// Update path-based wikilinks across the vault.
// Replace [[folder/slug]] with [[slug]] (title-based).
// Build a single regex matching any old path stem.
let path_stems: Vec<&str> = moves.iter().map(|(_, _, stem)| stem.as_str()).collect();
if !path_stems.is_empty() {
let escaped: Vec<String> = path_stems.iter().map(|s| regex::escape(s)).collect();
let pattern_str = format!(r"\[\[({})\]\]", escaped.join("|"));
if let Ok(re) = Regex::new(&pattern_str) {
// Collect all .md files in vault (now at root + keep folders)
let all_md: Vec<std::path::PathBuf> = WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md")
})
.map(|e| e.into_path())
.collect();
for md_path in &all_md {
let content = match fs::read_to_string(md_path) {
Ok(c) => c,
Err(_) => continue,
};
if !re.is_match(&content) {
continue;
}
let replaced = re.replace_all(&content, |caps: &regex::Captures| {
let matched = caps.get(1).map(|m| m.as_str()).unwrap_or("");
// Extract just the filename stem (last segment)
let slug = matched.rsplit('/').next().unwrap_or(matched);
format!("[[{}]]", slug)
});
if replaced != content {
let _ = fs::write(md_path, replaced.as_ref());
}
}
}
}
// Clean up empty directories (only type-folder directories, not KEEP_FOLDERS)
for (old, _, _) in &moves {
if let Some(parent) = old.parent() {
if parent != vault {
let _ = fs::remove_dir(parent); // only succeeds if empty
}
}
}
Ok(moved)
}
/// Result of a vault health check.
#[derive(Debug, Serialize, Default)]
pub struct VaultHealthReport {
/// Files in non-protected subfolders (won't be scanned by scan_vault).
pub stray_files: Vec<String>,
/// Files whose filename doesn't match slugify(title).
pub title_mismatches: Vec<TitleMismatch>,
}
/// A single filename-title mismatch.
#[derive(Debug, Serialize)]
pub struct TitleMismatch {
pub path: String,
pub filename: String,
pub title: String,
pub expected_filename: String,
}
/// Slugify a title to produce the expected filename stem.
fn slugify(text: &str) -> String {
let result: String = text
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let trimmed = result.trim_matches('-').to_string();
// Collapse consecutive dashes
let mut prev_dash = false;
let collapsed: String = trimmed
.chars()
.filter(|&c| {
if c == '-' {
if prev_dash {
return false;
}
prev_dash = true;
} else {
prev_dash = false;
}
true
})
.collect();
if collapsed.is_empty() {
"untitled".to_string()
} else {
collapsed
}
}
/// Check vault health: detect stray files and filename-title mismatches.
pub fn vault_health_check(vault_path: &str) -> Result<VaultHealthReport, String> {
let vault = Path::new(vault_path);
if !vault.exists() || !vault.is_dir() {
return Err(format!(
"Vault path does not exist or is not a directory: {}",
vault_path
));
}
let mut report = VaultHealthReport::default();
// 1. Detect stray files in non-protected subfolders
for entry in WalkDir::new(vault)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
// Skip root files (they're fine)
if path.parent() == Some(vault) {
continue;
}
let rel = path.strip_prefix(vault).unwrap_or(path);
let top_folder = rel
.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.unwrap_or_default();
if KEEP_FOLDERS.iter().any(|&k| k == top_folder) || top_folder.starts_with('.') {
continue;
}
report.stray_files.push(rel.to_string_lossy().to_string());
}
// 2. Detect filename-title mismatches (root .md files only)
if let Ok(dir_entries) = fs::read_dir(vault) {
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
for dir_entry in dir_entries.flatten() {
let path = dir_entry.path();
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
continue;
}
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let parsed = matter.parse(&content);
let fm_title = parsed.data.as_ref().and_then(|pod| {
if let gray_matter::Pod::Hash(ref map) = pod {
if let Some(gray_matter::Pod::String(s)) = map.get("title") {
return Some(s.as_str());
}
}
None
});
let title = super::parsing::extract_title(fm_title, &content, &filename);
let expected_stem = slugify(&title);
let expected_filename = format!("{}.md", expected_stem);
let current_stem = filename.strip_suffix(".md").unwrap_or(&filename);
if current_stem != expected_stem {
report.title_mismatches.push(TitleMismatch {
path: path.to_string_lossy().to_string(),
filename: filename.clone(),
title,
expected_filename,
});
}
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -561,226 +253,4 @@ mod tests {
let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap();
assert_eq!(count, 0, "non-markdown files should be ignored");
}
// --- flatten_vault ---
fn write_nested_file(
dir: &std::path::Path,
rel_path: &str,
content: &str,
) -> std::path::PathBuf {
let path = dir.join(rel_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
path
}
#[test]
fn test_flatten_vault_moves_notes_to_root() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
write_nested_file(
vault,
"project/my-proj.md",
"---\ntype: Project\n---\n# My Proj\n",
);
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 2);
assert!(vault.join("hello.md").exists());
assert!(vault.join("my-proj.md").exists());
assert!(!vault.join("note/hello.md").exists());
assert!(!vault.join("project/my-proj.md").exists());
}
#[test]
fn test_flatten_vault_skips_protected_folders() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_nested_file(vault, "attachments/image.md", "# Image note\n");
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 1);
assert!(vault.join("hello.md").exists());
assert!(vault.join("attachments/image.md").exists());
}
#[test]
fn test_flatten_vault_handles_filename_collision() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "hello.md", "---\ntype: Note\n---\n# Root Hello\n");
write_nested_file(
vault,
"note/hello.md",
"---\ntype: Note\n---\n# Note Hello\n",
);
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 1);
assert!(vault.join("hello.md").exists());
assert!(vault.join("hello-2.md").exists());
// Root file unchanged
let root_content = fs::read_to_string(vault.join("hello.md")).unwrap();
assert!(root_content.contains("Root Hello"));
// Moved file gets suffixed name
let moved_content = fs::read_to_string(vault.join("hello-2.md")).unwrap();
assert!(moved_content.contains("Note Hello"));
}
#[test]
fn test_flatten_vault_updates_path_wikilinks() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_nested_file(
vault,
"note/hello.md",
"---\ntype: Note\n---\n# Hello\n\nSee [[project/my-proj]] for details.\n",
);
write_nested_file(
vault,
"project/my-proj.md",
"---\ntype: Project\n---\n# My Proj\n",
);
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 2);
let content = fs::read_to_string(vault.join("hello.md")).unwrap();
assert!(content.contains("[[my-proj]]"));
assert!(!content.contains("[[project/my-proj]]"));
}
#[test]
fn test_flatten_vault_noop_when_already_flat() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "hello.md", "---\ntype: Note\n---\n# Hello\n");
write_file(vault, "world.md", "---\ntype: Note\n---\n# World\n");
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_flatten_vault_nested_subfolders() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_nested_file(vault, "note/sub/deep.md", "---\ntype: Note\n---\n# Deep\n");
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
assert_eq!(count, 1);
assert!(vault.join("deep.md").exists());
}
#[test]
fn test_flatten_vault_cleans_empty_directories() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
flatten_vault(vault.to_str().unwrap()).unwrap();
assert!(
!vault.join("note").exists(),
"empty folder should be removed"
);
}
// --- slugify ---
#[test]
fn test_slugify_basic() {
assert_eq!(slugify("Hello World"), "hello-world");
}
#[test]
fn test_slugify_special_chars() {
assert_eq!(slugify("My Note (v2)!"), "my-note-v2");
assert_eq!(slugify("Sprint Retrospective"), "sprint-retrospective");
}
#[test]
fn test_slugify_empty() {
assert_eq!(slugify(""), "untitled");
}
#[test]
fn test_slugify_unicode() {
assert_eq!(slugify("Café Résumé"), "caf-r-sum");
}
// --- vault_health_check ---
#[test]
fn test_health_check_detects_stray_files() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "root-note.md", "---\ntype: Note\n---\n# Root Note\n");
write_file(vault, "project.md", "---\ntype: Type\n---\n# Project\n");
write_nested_file(
vault,
"old-folder/stray.md",
"---\ntype: Note\n---\n# Stray\n",
);
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
assert_eq!(report.stray_files.len(), 1);
assert!(report.stray_files[0].contains("stray.md"));
}
#[test]
fn test_health_check_no_stray_when_flat() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "my-note.md", "# My Note\n");
write_file(vault, "project.md", "---\ntype: Type\n---\n# Project\n");
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
assert!(report.stray_files.is_empty());
}
#[test]
fn test_health_check_detects_title_mismatch() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
// Filename is "wrong-name.md" but title frontmatter says "My Actual Title"
write_file(
vault,
"wrong-name.md",
"---\ntitle: My Actual Title\ntype: Note\n---\n# My Actual Title\n",
);
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
assert_eq!(report.title_mismatches.len(), 1);
assert_eq!(report.title_mismatches[0].filename, "wrong-name.md");
assert_eq!(
report.title_mismatches[0].expected_filename,
"my-actual-title.md"
);
}
#[test]
fn test_health_check_no_mismatch_when_correct() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "my-note.md", "---\ntype: Note\n---\n# My Note\n");
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
assert!(report.title_mismatches.is_empty());
}
#[test]
fn test_health_check_skips_hidden_folders() {
let tmp = tempdir().unwrap();
let vault = tmp.path();
write_file(vault, "root.md", "# Root\n");
write_nested_file(vault, ".git/config.md", "# Git Config\n");
write_nested_file(vault, ".laputa/cache.md", "# Cache\n");
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
assert!(report.stray_files.is_empty());
}
}

View File

@@ -13,12 +13,14 @@ mod trash;
pub use cache::{invalidate_cache, scan_vault_cached};
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
pub use entry::VaultEntry;
pub use entry::{FolderNode, VaultEntry};
pub use file::{get_note_content, save_note_content};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::{copy_image_to_vault, save_image};
pub use migration::{flatten_vault, migrate_is_a_to_type, vault_health_check, VaultHealthReport};
pub use rename::{rename_note, RenameResult};
pub use migration::migrate_is_a_to_type;
pub use rename::{
detect_renames, rename_note, update_wikilinks_for_renames, DetectedRename, RenameResult,
};
pub use title_sync::{sync_title_on_open, SyncAction};
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
@@ -43,7 +45,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(&content);
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data);
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
let title = extract_title(frontmatter.title.as_deref(), &content, &filename);
let snippet = extract_snippet(&content);
@@ -114,9 +116,12 @@ pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
parse_md_file(path)
}
/// Folders that are scanned recursively (attachments, assets).
/// All other subfolders are ignored — notes and type definitions live flat at the vault root.
const PROTECTED_FOLDERS: &[&str] = &["attachments", "assets"];
/// Directories that are never shown in the folder tree or scanned for notes.
const HIDDEN_DIRS: &[&str] = &[".git", ".laputa", ".DS_Store"];
fn is_hidden_dir(name: &str) -> bool {
name.starts_with('.') || HIDDEN_DIRS.contains(&name)
}
fn is_md_file(path: &Path) -> bool {
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
@@ -129,31 +134,25 @@ fn try_parse_md(path: &Path, entries: &mut Vec<VaultEntry>) {
}
}
fn scan_root_md_files(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
let dir_entries = match fs::read_dir(vault_path) {
Ok(d) => d,
Err(_) => return,
};
for dir_entry in dir_entries.flatten() {
let path = dir_entry.path();
if is_md_file(&path) {
try_parse_md(&path, entries);
}
}
}
fn scan_protected_folders(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
for folder in PROTECTED_FOLDERS {
let folder_path = vault_path.join(folder);
if !folder_path.is_dir() {
continue;
}
let md_files = WalkDir::new(&folder_path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| is_md_file(e.path()));
for entry in md_files {
/// Scan all .md files in the vault, including subdirectories.
/// Hidden directories (starting with `.`) are excluded.
fn scan_all_md_files(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
let walker = WalkDir::new(vault_path)
.follow_links(true)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_dir() {
let name = e.file_name().to_string_lossy();
// Skip the vault root itself (depth 0) — we only filter subdirs
if e.depth() == 0 {
return true;
}
return !is_hidden_dir(&name);
}
true
});
for entry in walker.filter_map(|e| e.ok()) {
if is_md_file(entry.path()) {
try_parse_md(entry.path(), entries);
}
}
@@ -175,13 +174,51 @@ pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
}
let mut entries = Vec::new();
scan_root_md_files(vault_path, &mut entries);
scan_protected_folders(vault_path, &mut entries);
scan_all_md_files(vault_path, &mut entries);
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
Ok(entries)
}
/// Build a tree of visible folders in the vault.
/// Excludes hidden directories (starting with `.`).
pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
if !vault_path.is_dir() {
return Err(format!("Not a directory: {}", vault_path.display()));
}
fn build_tree(dir: &Path, vault_root: &Path) -> Vec<FolderNode> {
let mut nodes: Vec<FolderNode> = Vec::new();
let entries = match fs::read_dir(dir) {
Ok(d) => d,
Err(_) => return nodes,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if is_hidden_dir(&name) {
continue;
}
let rel_path = path
.strip_prefix(vault_root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let children = build_tree(&path, vault_root);
nodes.push(FolderNode {
name,
path: rel_path,
children,
});
}
nodes.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
nodes
}
Ok(build_tree(vault_path, vault_path))
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;

View File

@@ -145,7 +145,7 @@ fn test_scan_vault_root_and_protected_folders() {
}
#[test]
fn test_scan_vault_skips_non_protected_subfolders() {
fn test_scan_vault_includes_subdirectory_notes() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "root.md", "# Root Note\n");
create_test_file(
@@ -160,8 +160,15 @@ fn test_scan_vault_skips_non_protected_subfolders() {
);
let entries = scan_vault(dir.path()).unwrap();
assert_eq!(entries.len(), 1, "only root .md files should be scanned");
assert_eq!(entries[0].filename, "root.md");
assert_eq!(
entries.len(),
3,
"all .md files including subdirs should be scanned"
);
let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect();
assert!(filenames.contains(&"root.md"));
assert!(filenames.contains(&"nested.md"));
assert!(filenames.contains(&"old-project.md"));
}
#[test]
@@ -1096,6 +1103,142 @@ fn test_parse_visible_missing_defaults_to_none() {
assert_eq!(entry.visible, None);
}
// --- Regression: trashed/archived must survive unquoted date in "Trashed at" ---
#[test]
fn test_trashed_true_with_unquoted_date_in_trashed_at() {
let dir = TempDir::new().unwrap();
// Reproduces the engineering-management.md scenario: Trashed at has an
// unquoted YAML date (2026-03-11) which gray_matter may parse as a non-string.
// The entire Frontmatter deserialization must NOT fail because of this.
let content = "---\ntype: Topic\nTrashed: true\n\"Trashed at\": 2026-03-11\n---\n# Engineering Management\n";
let entry = parse_test_entry(&dir, "engineering-management.md", content);
assert!(
entry.trashed,
"Trashed must be true even when 'Trashed at' contains an unquoted date"
);
}
#[test]
fn test_archived_true_with_extra_non_string_fields() {
let dir = TempDir::new().unwrap();
// If any StringOrList field gets a non-string value, archived must still parse.
let content = "---\nArchived: true\norder: 5\n---\n# Archived Note\n";
let entry = parse_test_entry(&dir, "archived-extra.md", content);
assert!(
entry.archived,
"Archived must be true even with other fields present"
);
}
#[test]
fn test_trashed_with_reviewed_false_field() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Topic\nReviewed: False\nTrashed: true\n---\n# Test\n";
let entry = parse_test_entry(&dir, "reviewed-test.md", content);
assert!(
entry.trashed,
"Trashed must be true even when frontmatter contains Reviewed: False"
);
assert_eq!(entry.is_a, Some("Topic".to_string()));
}
/// Regression: wikilinks containing curly braces + nested quotes in YAML arrays
/// cause gray_matter to produce Hash values instead of strings for array elements.
/// This must NOT make parse_frontmatter fall back to default (losing trashed/archived).
#[test]
fn test_trashed_survives_malformed_wikilinks_in_yaml() {
let dir = TempDir::new().unwrap();
// This YAML has curly braces inside a double-quoted string, producing nested
// Hash values in some YAML parsers. The Frontmatter serde must not fail.
let content = "---\ntype: Topic\nNotes:\n - \"[[foo|bar]]\"\n - \"[[slug|{'Title': 'Subtitle'}]]\"\nTrashed: true\n---\n# Test\n";
let entry = parse_test_entry(&dir, "malformed-links.md", content);
assert!(
entry.trashed,
"Trashed must be true even with curly-brace wikilinks in frontmatter arrays"
);
}
/// Regression: files with malformed YAML (e.g. Notion exports with unescaped quotes
/// in wikilinks) cause gray_matter to return Null instead of a Hash. The fallback
/// parser must still extract Trashed, type, and other simple key:value fields.
#[test]
fn test_parse_real_engineering_management_file() {
let path = std::path::Path::new("/Users/luca/Laputa/engineering-management.md");
if !path.exists() {
return; // Skip when the Laputa vault is not available
}
let entry = parse_md_file(path).unwrap();
assert!(
entry.trashed,
"engineering-management.md must be trashed (has Trashed: true in frontmatter)"
);
assert_eq!(entry.is_a, Some("Topic".to_string()));
}
#[test]
fn test_fallback_parser_extracts_trashed_from_malformed_yaml() {
let dir = TempDir::new().unwrap();
// Simulate malformed YAML that gray_matter can't parse: unescaped double
// quotes inside a double-quoted YAML string cause the YAML parser to fail.
// The fallback line-by-line parser must still extract simple key:value pairs.
//
// Write the file manually with literal unescaped quotes (can't use Rust string
// escaping for this since the YAML itself is the malformed part).
let fm = [
"---",
"type: Topic",
"Status: Draft",
"Belongs to:",
" - \"[[engineering|Engineering]]\"",
"aliases:",
" - Engineering Management",
"Notes:",
// This line has unescaped " inside a "-quoted YAML string — malformed YAML
" - \"[[slug|{\"Title\": 'Subtitle'}]]\"",
"Trashed: true",
"\"Trashed at\": 2026-03-11",
"---",
"",
"# Engineering Management",
];
let content = fm.join("\n");
create_test_file(dir.path(), "eng-mgmt.md", &content);
let entry = parse_md_file(&dir.path().join("eng-mgmt.md")).unwrap();
assert!(
entry.trashed,
"Trashed must be true even when YAML is malformed (fallback parser)"
);
assert_eq!(
entry.is_a,
Some("Topic".to_string()),
"isA must be extracted by fallback parser"
);
}
#[test]
fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
let dir = TempDir::new().unwrap();
let fm = [
"---",
"type: Essay",
"Notes:",
" - \"[[slug|{\"Broken\": 'quotes'}]]\"",
"Archived: true",
"---",
"",
"# Archived Essay",
];
let content = fm.join("\n");
create_test_file(dir.path(), "archived-essay.md", &content);
let entry = parse_md_file(&dir.path().join("archived-essay.md")).unwrap();
assert!(
entry.archived,
"Archived must be true even when YAML is malformed"
);
assert_eq!(entry.is_a, Some("Essay".to_string()));
}
#[test]
fn test_visible_not_in_relationships() {
let dir = TempDir::new().unwrap();
@@ -1211,6 +1354,46 @@ fn test_array_field_does_not_break_type_detection() {
assert_eq!(entry.status, Some("Active".to_string()));
}
// ── Folder tree tests ──────────────────────────────────────────────────────
#[test]
fn test_scan_vault_folders_returns_tree() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("projects/laputa")).unwrap();
fs::create_dir_all(dir.path().join("areas")).unwrap();
let folders = scan_vault_folders(dir.path()).unwrap();
let names: Vec<&str> = folders.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"projects"));
assert!(names.contains(&"areas"));
let projects = folders.iter().find(|f| f.name == "projects").unwrap();
assert_eq!(projects.children.len(), 1);
assert_eq!(projects.children[0].name, "laputa");
assert_eq!(projects.children[0].path, "projects/laputa");
}
#[test]
fn test_scan_vault_folders_excludes_hidden() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
fs::create_dir_all(dir.path().join(".laputa")).unwrap();
fs::create_dir_all(dir.path().join("visible")).unwrap();
let folders = scan_vault_folders(dir.path()).unwrap();
assert_eq!(folders.len(), 1);
assert_eq!(folders[0].name, "visible");
}
#[test]
fn test_scan_vault_folders_flat_vault() {
let dir = TempDir::new().unwrap();
create_test_file(dir.path(), "note.md", "# Note\n");
let folders = scan_vault_folders(dir.path()).unwrap();
assert!(folders.is_empty(), "flat vault has no visible folders");
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -144,9 +144,10 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
}
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
/// `exclude` is the source file being renamed — it should not be treated as a collision.
fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::path::PathBuf {
let dest = dest_dir.join(filename);
if !dest.exists() {
if !dest.exists() || dest == exclude {
return dest;
}
let stem = Path::new(filename)
@@ -160,7 +161,7 @@ fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
let mut counter = 2;
loop {
let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
if !candidate.exists() {
if !candidate.exists() || candidate == exclude {
return candidate;
}
counter += 1;
@@ -223,7 +224,7 @@ pub fn rename_note(
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = unique_dest_path(parent_dir, &expected_filename);
let new_file = unique_dest_path(parent_dir, &expected_filename, old_file);
let new_path_str = new_file.to_string_lossy().to_string();
fs::write(&new_file, &updated_content)
@@ -250,6 +251,89 @@ pub fn rename_note(
})
}
/// A detected rename: old path → new path (both relative to vault root).
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DetectedRename {
pub old_path: String,
pub new_path: String,
}
/// Detect renamed files by comparing working tree against HEAD using git diff.
pub fn detect_renames(vault_path: &str) -> Result<Vec<DetectedRename>, String> {
let vault = Path::new(vault_path);
let output = std::process::Command::new("git")
.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff: {e}"))?;
if !output.status.success() {
return Ok(vec![]); // No HEAD yet or other git issue — no renames
}
let stdout = String::from_utf8_lossy(&output.stdout);
let renames: Vec<DetectedRename> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 3 && parts[0].starts_with('R') {
let old = parts[1].to_string();
let new = parts[2].to_string();
if old.ends_with(".md") && new.ends_with(".md") {
return Some(DetectedRename {
old_path: old,
new_path: new,
});
}
}
None
})
.collect();
Ok(renames)
}
/// Update wikilinks across the vault for a list of detected renames.
/// Returns the total number of files updated.
pub fn update_wikilinks_for_renames(
vault_path: &str,
renames: &[DetectedRename],
) -> Result<usize, String> {
let vault = Path::new(vault_path);
let mut total_updated = 0;
for rename in renames {
let old_stem = rename
.old_path
.strip_suffix(".md")
.unwrap_or(&rename.old_path);
let new_stem = rename
.new_path
.strip_suffix(".md")
.unwrap_or(&rename.new_path);
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(old_stem);
let new_filename_stem = new_stem.split('/').next_back().unwrap_or(new_stem);
// Build title from filename stem (kebab-case → Title Case)
let old_title = super::parsing::slug_to_title(old_filename_stem);
let new_title = super::parsing::slug_to_title(new_filename_stem);
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
let new_file = vault.join(&rename.new_path);
let updated = update_wikilinks_in_vault(&WikilinkReplacement {
vault_path: vault,
old_title: &old_title,
new_title: &new_title,
old_path_stem: old_filename_stem,
exclude_path: &new_file,
});
total_updated += updated;
}
Ok(total_updated)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -22,7 +22,8 @@
"fullscreen": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"backgroundColor": "#F7F6F3"
"backgroundColor": "#F7F6F3",
"dragDropEnabled": false
}
],
"security": {

View File

@@ -2,8 +2,8 @@
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
height: 100%;
width: 100%;
overflow: hidden;
box-shadow: inset 0 0 0 1px var(--border-primary);
}
@@ -39,11 +39,13 @@
.app__editor {
flex: 1;
min-width: 800px;
min-width: 400px;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
container-type: inline-size;
container-name: editor;
}
.app__editor > * {

View File

@@ -63,6 +63,7 @@ const mockAllContent: Record<string, string> = {
const mockCommandResults: Record<string, unknown> = {
list_vault: mockEntries,
list_vault_folders: [],
get_all_content: mockAllContent,
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',

View File

@@ -47,8 +47,6 @@ import { useVaultBridge } from './hooks/useVaultBridge'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
import { UpdateBanner } from './components/UpdateBanner'
import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner'
import { useFlatVaultMigration } from './hooks/useFlatVaultMigration'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, InboxPeriod } from './types'
@@ -56,6 +54,8 @@ import type { NoteListItem } from './utils/ai-context'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
import { GitRequiredModal } from './components/GitRequiredModal'
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
import './App.css'
// Type declarations for mock content storage and test overrides
@@ -95,11 +95,28 @@ function App() {
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
// Git repo check: 'checking' | 'required' | 'ready'
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
useEffect(() => {
if (!resolvedPath) return
setGitRepoState('checking')
const check = isTauri()
? invoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
: Promise.resolve(true) // browser mock: assume git
check
.then(isGit => setGitRepoState(isGit ? 'ready' : 'required'))
.catch(() => setGitRepoState('ready')) // fail open
}, [resolvedPath])
const handleInitGitRepo = useCallback(async () => {
if (isTauri()) await invoke('init_git_repo', { vaultPath: resolvedPath })
setGitRepoState('ready')
}, [resolvedPath])
const vault = useVaultLoader(resolvedPath)
useVaultConfig(resolvedPath)
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
useTelemetry(settings, settingsLoaded)
const flatVaultMigration = useFlatVaultMigration(resolvedPath, vault.entries.length > 0, vault.reloadVault)
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const autoSync = useAutoSync({
@@ -113,6 +130,33 @@ function App() {
onToast: (msg) => setToastMessage(msg),
})
// Detect external file renames on window focus
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
useEffect(() => {
if (!isTauri() || !resolvedPath) return
const handleFocus = () => {
invoke<DetectedRename[]>('detect_renames', { vaultPath: resolvedPath })
.then(renames => { if (renames.length > 0) setDetectedRenames(renames) })
.catch(() => {}) // ignore errors (e.g., no git)
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [resolvedPath])
const handleUpdateWikilinks = useCallback(async () => {
if (!isTauri()) return
try {
const count = await invoke<number>('update_wikilinks_for_renames', { vaultPath: resolvedPath, renames: detectedRenames })
setDetectedRenames([])
vault.reloadVault()
setToastMessage(`Updated wikilinks in ${count} file${count !== 1 ? 's' : ''}`)
} catch (err) {
setToastMessage(`Failed to update wikilinks: ${err}`)
}
}, [resolvedPath, detectedRenames, vault, setToastMessage])
const handleDismissRenames = useCallback(() => setDetectedRenames([]), [])
const conflictResolver = useConflictResolver({
vaultPath: resolvedPath,
onResolved: () => {
@@ -210,6 +254,12 @@ function App() {
onVaultChanged: () => { vault.reloadVault() },
})
const handleInitializeProperties = useCallback(async (path: string) => {
const filename = path.split('/').pop()?.replace(/\.md$/, '') ?? 'Untitled'
await notes.handleUpdateFrontmatter(path, 'type', 'Note', { silent: true })
await notes.handleUpdateFrontmatter(path, 'title', filename)
}, [notes])
const handleSetNoteIcon = useCallback(async (path: string, emoji: string) => {
await notes.handleUpdateFrontmatter(path, 'icon', emoji)
}, [notes])
@@ -222,6 +272,20 @@ function App() {
window.dispatchEvent(new CustomEvent('laputa:open-icon-picker'))
}, [])
const handleCreateFolder = useCallback(async (name: string) => {
try {
if (isTauri()) {
await invoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
} else {
await mockInvoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
}
await vault.reloadVault()
setToastMessage(`Created folder "${name}"`)
} catch (e) {
setToastMessage(`Failed to create folder: ${e}`)
}
}, [resolvedPath, vault, setToastMessage])
const handleRemoveNoteIconCommand = useCallback(() => {
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
}, [notes.activeTabPath, handleRemoveNoteIcon])
@@ -386,6 +450,23 @@ function App() {
return <LoadingView />
}
// Show git-required modal when vault has no git repo (skip for note windows)
if (!noteWindowParams && gitRepoState === 'required') {
return (
<div className="app-shell">
<GitRequiredModal
onCreateRepo={handleInitGitRepo}
onChooseVault={vaultSwitcher.handleOpenLocalFolder}
/>
</div>
)
}
// Show loading spinner while checking git status
if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') {
return <LoadingView />
}
// Show telemetry consent dialog on first launch (skip for note windows)
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
return (
@@ -407,7 +488,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} inboxCount={inboxCount} />
<Sidebar entries={vault.entries} folders={vault.folders} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -445,6 +526,7 @@ function App() {
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
@@ -476,18 +558,8 @@ function App() {
/>
</div>
</div>
{flatVaultMigration.needsMigration && (
<FlatVaultMigrationBanner
strayFileCount={flatVaultMigration.strayFiles.length}
isMigrating={flatVaultMigration.isMigrating}
onMigrate={async () => {
const count = await flatVaultMigration.migrate()
setToastMessage(`Migrated ${count} file${count !== 1 ? 's' : ''} to vault root`)
}}
onDismiss={flatVaultMigration.dismiss}
/>
)}
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<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={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />

View File

@@ -111,6 +111,41 @@ describe('BreadcrumbBar — archive/unarchive', () => {
})
})
describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => {
it('always renders title elements in the DOM', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
expect(screen.getByText('Note')).toBeInTheDocument()
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByText('Test Note')).toBeInTheDocument()
})
it('shows emoji icon when entry has an emoji icon', () => {
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
expect(screen.getByText('🚀')).toBeInTheDocument()
})
it('does not show icon when entry has a non-emoji icon', () => {
const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' }
render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} />)
expect(screen.queryByText('cooking-pot')).not.toBeInTheDocument()
})
it('falls back to "Note" when isA is null', () => {
const entryNoType = { ...baseEntry, isA: null }
render(<BreadcrumbBar entry={entryNoType} {...defaultProps} />)
expect(screen.getByText('Note')).toBeInTheDocument()
})
it('shadow is controlled by data-title-hidden attribute via CSS', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.querySelector('.breadcrumb-bar')!
expect(bar).not.toHaveAttribute('data-title-hidden')
bar.setAttribute('data-title-hidden', '')
expect(bar).toHaveAttribute('data-title-hidden')
})
})
describe('BreadcrumbBar — raw editor toggle', () => {
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
const onToggleRaw = vi.fn()

View File

@@ -32,6 +32,8 @@ interface BreadcrumbBarProps {
onRestore?: () => void
onArchive?: () => void
onUnarchive?: () => void
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
@@ -161,19 +163,37 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
)
}
function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
const typeLabel = entry.isA ?? 'Note'
const icon = entry.icon
const emojiIcon = icon && /^\p{Emoji}/u.test(icon) ? icon : null
return (
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
<span className="shrink-0">{typeLabel}</span>
<span className="shrink-0 text-border"></span>
{emojiIcon && <span className="shrink-0">{emojiIcon}</span>}
<span className="truncate font-medium text-foreground">{entry.title}</span>
</div>
)
}
export const BreadcrumbBar = memo(function BreadcrumbBar({
entry, ...actionProps
entry, barRef, ...actionProps
}: BreadcrumbBarProps) {
return (
<div
ref={barRef}
data-tauri-drag-region
className="flex shrink-0 items-center justify-end"
className="breadcrumb-bar flex shrink-0 items-center"
style={{
height: 52,
background: 'var(--background)',
padding: '6px 16px',
}}
>
<div className="breadcrumb-bar__title flex-1 min-w-0">
<BreadcrumbTitle entry={entry} />
</div>
<BreadcrumbActions entry={entry} {...actionProps} />
</div>
)

View File

@@ -99,10 +99,10 @@ export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCa
}
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className="inline-flex h-6 min-w-0 items-center gap-1.5">
{showSwatch && <ColorSwatch color={value} onChange={handlePickerChange} />}
<span
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
className="min-w-0 cursor-pointer truncate rounded px-1 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
>

View File

@@ -53,7 +53,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
}
return (
<div className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
<div className="group/prop grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
<span className="flex min-w-0 items-center gap-1 text-[12px] text-muted-foreground">
<span className="truncate">{toSentenceCase(propKey)}</span>
{onDelete && (

View File

@@ -66,7 +66,7 @@ export function UrlValue({
return (
<span className="group/url flex min-w-0 max-w-full items-center gap-1">
<span
className="min-w-0 cursor-pointer truncate rounded-md px-2 py-1 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
className="inline-flex h-6 min-w-0 cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
onClick={handleOpen}
title={value}
data-testid="url-link"
@@ -125,7 +125,7 @@ export function EditableValue({
return (
<span
className="min-w-0 max-w-full cursor-pointer truncate rounded-md px-2 py-1 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
>
@@ -212,11 +212,11 @@ export function TagPillList({
) : (
<span
key={idx}
className="group/pill relative inline-flex cursor-pointer items-center rounded-md transition-colors"
className="group/pill relative inline-flex h-6 cursor-pointer items-center rounded-md transition-colors"
style={{
...getTagStyle(item),
backgroundColor: getTagStyle(item).bg,
padding: '4px 8px',
padding: '0 8px',
fontSize: 12,
fontWeight: 500,
}}
@@ -255,8 +255,8 @@ export function TagPillList({
/>
) : (
<button
className="inline-flex items-center justify-center rounded-md border-none bg-muted px-2 text-[12px] font-medium leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ padding: '4px 8px' }}
className="inline-flex h-6 items-center justify-center rounded-md border-none bg-muted px-2 text-[12px] font-medium leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ padding: '0 8px' }}
onClick={() => setIsAddingNew(true)}
title={`Add ${label.toLowerCase()}`}
>

View File

@@ -23,6 +23,24 @@
opacity: 0.55;
}
/* Breadcrumb bar: title + shadow toggled via data attribute (no React re-render) */
.breadcrumb-bar {
transition: box-shadow 0.2s ease;
box-shadow: none;
}
.breadcrumb-bar[data-title-hidden] {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.breadcrumb-bar__title {
display: none;
}
.breadcrumb-bar[data-title-hidden] .breadcrumb-bar__title {
display: flex;
}
/* Scroll area wrapping title + editor — single scroll context for alignment */
.editor-scroll-area {
flex: 1;
@@ -341,3 +359,13 @@
.editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
display: none;
}
/* Reduce padding at narrow editor widths so content isn't cramped */
@container editor (max-width: 600px) {
.editor__blocknote-container .bn-editor {
padding: 12px 16px;
}
.title-section {
padding: 0 16px;
}
}

View File

@@ -467,7 +467,7 @@ describe('wikilink autocomplete', () => {
expect(items.length).toBeGreaterThan(0)
items[0].onItemClick()
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'Alpha Project' } },
{ type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } },
' ',
])
})
@@ -620,7 +620,7 @@ describe('person @mention autocomplete', () => {
expect(items.length).toBeGreaterThan(0)
items[0].onItemClick()
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'Matteo Cellini' } },
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } },
' ',
])
})

View File

@@ -41,6 +41,7 @@ interface EditorProps {
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
showAIChat?: boolean
onToggleAIChat?: () => void
vaultPath?: string
@@ -202,7 +203,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
@@ -286,6 +287,8 @@ export const Editor = memo(function Editor(props: EditorProps) {
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onToggleRawEditor={handleToggleRawExclusive}
onOpenNote={onNavigateWikilink}
onFileCreated={onFileCreated}
onFileModified={onFileModified}

View File

@@ -1,5 +1,5 @@
import type React from 'react'
import { useCallback } from 'react'
import { useCallback, useRef, useEffect } from 'react'
import type { VaultEntry, NoteStatus } from '../types'
import type { useCreateBlockNote } from '@blocknote/react'
import { DiffView } from './DiffView'
@@ -92,7 +92,7 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
}
function RawModeEditorSection({
rawMode, activeTab, entries, onContentChange, onSave, latestContentRef,
rawMode, activeTab, entries, onContentChange, onSave, latestContentRef, vaultPath,
}: {
rawMode: boolean
activeTab: Tab | null
@@ -100,6 +100,7 @@ function RawModeEditorSection({
onContentChange?: (path: string, content: string) => void
onSave?: () => void
latestContentRef?: React.MutableRefObject<string | null>
vaultPath?: string
}) {
if (!rawMode || !activeTab) return null
return (
@@ -111,6 +112,7 @@ function RawModeEditorSection({
onContentChange={onContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
latestContentRef={latestContentRef}
vaultPath={vaultPath}
/>
)
}
@@ -120,8 +122,9 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
return cb ? () => cb(path) : undefined
}
function ActiveTabBreadcrumb({ activeTab, props }: {
function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
activeTab: Tab
barRef: React.RefObject<HTMLDivElement | null>
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
}) {
const wordCount = countWords(activeTab.content)
@@ -130,6 +133,7 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
<BreadcrumbBar
entry={activeTab.entry}
wordCount={wordCount}
barRef={barRef}
showDiffToggle={props.showDiffToggle}
diffMode={props.diffMode}
diffLoading={props.diffLoading}
@@ -168,6 +172,24 @@ export function EditorContent({
const entryIcon = activeTab?.entry.icon ?? null
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
const titleSectionRef = useRef<HTMLDivElement | null>(null)
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const el = titleSectionRef.current
const bar = breadcrumbBarRef.current
if (!el || !bar) return
const observer = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) bar.removeAttribute('data-title-hidden')
else bar.setAttribute('data-title-hidden', '')
},
{ threshold: 0 },
)
observer.observe(el)
return () => { observer.disconnect(); bar.removeAttribute('data-title-hidden') }
}, [activeTab?.entry.path, showEditor])
const handleSetIcon = useCallback((emoji: string) => {
if (activeTab) onSetNoteIcon?.(activeTab.entry.path, emoji)
}, [activeTab, onSetNoteIcon])
@@ -181,6 +203,7 @@ export function EditorContent({
{activeTab && (
<ActiveTabBreadcrumb
activeTab={activeTab}
barRef={breadcrumbBarRef}
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
/>
)}
@@ -200,10 +223,10 @@ export function EditorContent({
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} />
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} vaultPath={vaultPath} />
{showEditor && activeTab && (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="title-section">
<div ref={titleSectionRef} className="title-section">
{!emojiIcon && (
<div className="title-section__add-icon">
<NoteIcon

View File

@@ -22,6 +22,8 @@ interface EditorRightPanelProps {
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onToggleRawEditor?: () => void
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
@@ -33,7 +35,7 @@ export function EditorRightPanel({
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onOpenNote,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
onFileCreated, onFileModified, onVaultChanged,
}: EditorRightPanelProps) {
if (showAIChat) {
@@ -79,6 +81,8 @@ export function EditorRightPanel({
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onCreateAndOpenNote={onCreateAndOpenNote}
onInitializeProperties={onInitializeProperties}
onToggleRawEditor={onToggleRawEditor}
/>
</div>
)

View File

@@ -1,37 +0,0 @@
interface FlatVaultMigrationBannerProps {
strayFileCount: number
isMigrating: boolean
onMigrate: () => void
onDismiss: () => void
}
/**
* Banner shown when the vault has files in non-protected subfolders.
* Offers to flatten them to the vault root.
*/
export function FlatVaultMigrationBanner({ strayFileCount, isMigrating, onMigrate, onDismiss }: FlatVaultMigrationBannerProps) {
return (
<div className="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-950 border-b border-amber-200 dark:border-amber-800 text-sm" data-testid="migration-banner">
<span className="flex-1 text-amber-800 dark:text-amber-200">
{strayFileCount} note{strayFileCount !== 1 ? 's' : ''} found in subfolders.
Flatten to vault root for consistent scanning.
</span>
<button
className="px-3 py-1 text-xs font-medium rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-50"
onClick={onMigrate}
disabled={isMigrating}
data-testid="migration-flatten-btn"
>
{isMigrating ? 'Migrating...' : 'Flatten Now'}
</button>
<button
className="px-2 py-1 text-xs text-amber-600 dark:text-amber-400 hover:underline"
onClick={onDismiss}
disabled={isMigrating}
data-testid="migration-dismiss-btn"
>
Dismiss
</button>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { FolderTree } from './FolderTree'
import type { FolderNode, SidebarSelection } from '../types'
const mockFolders: FolderNode[] = [
{
name: 'projects',
path: 'projects',
children: [
{ name: 'laputa', path: 'projects/laputa', children: [] },
{ name: 'portfolio', path: 'projects/portfolio', children: [] },
],
},
{ name: 'areas', path: 'areas', children: [] },
{ name: 'journal', path: 'journal', children: [] },
]
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
describe('FolderTree', () => {
it('renders nothing when folders is empty', () => {
const { container } = render(
<FolderTree folders={[]} selection={defaultSelection} onSelect={vi.fn()} />,
)
expect(container.firstChild).toBeNull()
})
it('renders FOLDERS header and top-level folders', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
expect(screen.getByText('FOLDERS')).toBeInTheDocument()
expect(screen.getByText('projects')).toBeInTheDocument()
expect(screen.getByText('areas')).toBeInTheDocument()
expect(screen.getByText('journal')).toBeInTheDocument()
})
it('does not show children initially', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
})
it('calls onSelect with folder kind when clicking a folder', () => {
const onSelect = vi.fn()
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={onSelect} />)
fireEvent.click(screen.getByText('projects'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' })
})
it('expands children when clicking a folder with children', () => {
const onSelect = vi.fn()
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={onSelect} />)
fireEvent.click(screen.getByText('projects'))
expect(screen.getByText('laputa')).toBeInTheDocument()
expect(screen.getByText('portfolio')).toBeInTheDocument()
})
it('collapses section when clicking FOLDERS header', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
expect(screen.getByText('projects')).toBeInTheDocument()
fireEvent.click(screen.getByText('FOLDERS'))
expect(screen.queryByText('projects')).not.toBeInTheDocument()
})
it('highlights selected folder', () => {
const sel: SidebarSelection = { kind: 'folder', path: 'areas' }
render(<FolderTree folders={mockFolders} selection={sel} onSelect={vi.fn()} />)
const btn = screen.getByText('areas').closest('button')!
expect(btn.className).toContain('text-primary')
})
})

View File

@@ -0,0 +1,159 @@
import { useState, useCallback, useRef, useEffect, memo } from 'react'
import { Folder, FolderOpen, CaretDown, CaretRight, Plus } from '@phosphor-icons/react'
import type { FolderNode, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
interface FolderTreeProps {
folders: FolderNode[]
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
onCreateFolder?: (name: string) => void
}
function FolderItem({
node, depth, selection, expanded, onToggle, onSelect,
}: {
node: FolderNode
depth: number
selection: SidebarSelection
expanded: Record<string, boolean>
onToggle: (path: string) => void
onSelect: (selection: SidebarSelection) => void
}) {
const isSelected = selection.kind === 'folder' && selection.path === node.path
const isExpanded = expanded[node.path] ?? false
const hasChildren = node.children.length > 0
const handleClick = () => {
onSelect({ kind: 'folder', path: node.path })
if (hasChildren) onToggle(node.path)
}
return (
<>
<button
className={cn(
'flex w-full items-center gap-2 rounded-[5px] border-none bg-transparent cursor-pointer text-left transition-colors',
isSelected
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
: 'text-foreground hover:bg-accent',
)}
style={{ padding: '5px 8px', paddingLeft: 8 + depth * 16, fontSize: 13 }}
onClick={handleClick}
title={node.path}
>
{isSelected || isExpanded ? (
<FolderOpen size={18} weight="fill" className="shrink-0" />
) : (
<Folder size={18} className="shrink-0" />
)}
<span className={cn('truncate', isSelected && 'font-medium')}>{node.name}</span>
</button>
{isExpanded && hasChildren && (
<div className="relative" style={{ paddingLeft: 15 }}>
<div
className="absolute top-0 bottom-0 bg-border"
style={{ left: 15 + depth * 16, width: 1, opacity: 0.3 }}
/>
{node.children.map((child) => (
<FolderItem
key={child.path}
node={child}
depth={depth + 1}
selection={selection}
expanded={expanded}
onToggle={onToggle}
onSelect={onSelect}
/>
))}
</div>
)}
</>
)
}
export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder }: FolderTreeProps) {
const [sectionCollapsed, setSectionCollapsed] = useState(false)
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const [isCreating, setIsCreating] = useState(false)
const [newFolderName, setNewFolderName] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const toggleFolder = useCallback((path: string) => {
setExpanded((prev) => ({ ...prev, [path]: !prev[path] }))
}, [])
useEffect(() => {
if (isCreating) inputRef.current?.focus()
}, [isCreating])
const handleCreateFolder = () => {
const name = newFolderName.trim()
if (name && onCreateFolder) {
onCreateFolder(name)
}
setIsCreating(false)
setNewFolderName('')
}
if (folders.length === 0 && !isCreating) return null
return (
<div style={{ padding: '8px 0' }}>
{/* Header */}
<button
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
style={{ padding: '6px 14px 6px 16px' }}
onClick={() => setSectionCollapsed((v) => !v)}
>
<div className="flex items-center gap-1">
{sectionCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>FOLDERS</span>
</div>
{onCreateFolder && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); setIsCreating(true); setSectionCollapsed(false) }}
data-testid="create-folder-btn"
/>
)}
</button>
{/* Tree */}
{!sectionCollapsed && (
<div className="flex flex-col gap-0.5" style={{ padding: '2px 6px 8px 14px' }}>
{folders.map((node) => (
<FolderItem
key={node.path}
node={node}
depth={0}
selection={selection}
expanded={expanded}
onToggle={toggleFolder}
onSelect={onSelect}
/>
))}
{isCreating && (
<div className="flex items-center gap-2" style={{ padding: '4px 8px' }}>
<Folder size={18} className="shrink-0 text-muted-foreground" />
<input
ref={inputRef}
className="flex-1 border border-border rounded bg-background px-1.5 py-0.5 text-[13px] text-foreground outline-none focus:border-primary"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder()
if (e.key === 'Escape') { setIsCreating(false); setNewFolderName('') }
}}
onBlur={handleCreateFolder}
placeholder="Folder name"
data-testid="new-folder-input"
/>
</div>
)}
</div>
)}
</div>
)
})

View File

@@ -0,0 +1,51 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { GitRequiredModal } from './GitRequiredModal'
describe('GitRequiredModal', () => {
it('renders title and explanation', () => {
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
expect(screen.getByText('Git repository required')).toBeInTheDocument()
expect(screen.getByText(/track changes/)).toBeInTheDocument()
})
it('renders both action buttons', () => {
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
expect(screen.getByText('Create repository')).toBeInTheDocument()
expect(screen.getByText('Choose another vault')).toBeInTheDocument()
})
it('calls onCreateRepo when primary button clicked', async () => {
const onCreateRepo = vi.fn().mockResolvedValue(undefined)
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
fireEvent.click(screen.getByText('Create repository'))
expect(onCreateRepo).toHaveBeenCalledOnce()
})
it('calls onChooseVault when secondary button clicked', () => {
const onChooseVault = vi.fn()
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={onChooseVault} />)
fireEvent.click(screen.getByText('Choose another vault'))
expect(onChooseVault).toHaveBeenCalledOnce()
})
it('disables buttons and shows spinner while creating', async () => {
let resolve: () => void
const onCreateRepo = vi.fn().mockReturnValue(new Promise<void>(r => { resolve = r }))
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
fireEvent.click(screen.getByText('Create repository'))
await waitFor(() => {
expect(screen.getByText('Creating…')).toBeInTheDocument()
})
resolve!()
})
it('shows error message when creation fails', async () => {
const onCreateRepo = vi.fn().mockRejectedValue(new Error('Permission denied'))
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
fireEvent.click(screen.getByText('Create repository'))
await waitFor(() => {
expect(screen.getByText(/Permission denied/)).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,57 @@
import { useState } from 'react'
import { GitBranch } from '@phosphor-icons/react'
interface GitRequiredModalProps {
onCreateRepo: () => Promise<void>
onChooseVault: () => void
}
export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredModalProps) {
const [creating, setCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleCreate = async () => {
setCreating(true)
setError(null)
try {
await onCreateRepo()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setCreating(false)
}
}
return (
<div className="flex h-full w-full items-center justify-center" style={{ background: 'var(--sidebar)' }}>
<div className="flex max-w-sm flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
<GitBranch size={36} className="text-muted-foreground" />
<h2 className="m-0 text-lg font-semibold text-foreground">Git repository required</h2>
<p className="m-0 text-center text-[13px] leading-relaxed text-muted-foreground">
Laputa uses a git repository to track changes, detect moved files, and keep your vault safe.
We'll create a local repo — no remote needed.
</p>
{error && (
<p className="m-0 rounded-md bg-destructive/10 px-3 py-2 text-center text-[12px] text-destructive">
{error}
</p>
)}
<div className="flex w-full flex-col gap-2">
<button
className="w-full cursor-pointer rounded-md bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleCreate}
disabled={creating}
>
{creating ? 'Creating' : 'Create repository'}
</button>
<button
className="w-full cursor-pointer rounded-md border border-border bg-transparent px-4 py-2 text-[13px] font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
onClick={onChooseVault}
disabled={creating}
>
Choose another vault
</button>
</div>
</div>
</div>
)
}

View File

@@ -619,4 +619,96 @@ Status: Active
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
})
})
describe('frontmatter state handling', () => {
const noFrontmatterEntry: VaultEntry = {
...mockEntry,
path: '/vault/plain-note.md',
filename: 'plain-note.md',
title: 'plain-note',
isA: null,
}
it('shows "Initialize properties" button when note has no frontmatter', () => {
render(
<Inspector
{...defaultProps}
entry={noFrontmatterEntry}
content="# Just a plain note\n\nNo frontmatter here."
onInitializeProperties={vi.fn()}
/>
)
expect(screen.getByText('Initialize properties')).toBeInTheDocument()
expect(screen.queryByText('Type')).not.toBeInTheDocument()
})
it('shows "Initialize properties" button when frontmatter is empty', () => {
render(
<Inspector
{...defaultProps}
entry={noFrontmatterEntry}
content="---\n---\n# Note with empty frontmatter"
onInitializeProperties={vi.fn()}
/>
)
expect(screen.getByText('Initialize properties')).toBeInTheDocument()
})
it('calls onInitializeProperties when button is clicked', () => {
const onInit = vi.fn()
render(
<Inspector
{...defaultProps}
entry={noFrontmatterEntry}
content="# Plain note"
onInitializeProperties={onInit}
/>
)
fireEvent.click(screen.getByText('Initialize properties'))
expect(onInit).toHaveBeenCalledWith('/vault/plain-note.md')
})
it('shows invalid frontmatter notice with fix button', () => {
render(
<Inspector
{...defaultProps}
entry={noFrontmatterEntry}
content={'---\n{broken yaml\n---\nBody'}
onToggleRawEditor={vi.fn()}
/>
)
expect(screen.getByText('Invalid properties')).toBeInTheDocument()
expect(screen.getByText('Fix in editor')).toBeInTheDocument()
})
it('calls onToggleRawEditor when fix button is clicked', () => {
const onToggle = vi.fn()
render(
<Inspector
{...defaultProps}
entry={noFrontmatterEntry}
content={'---\n{broken yaml\n---\nBody'}
onToggleRawEditor={onToggle}
/>
)
fireEvent.click(screen.getByText('Fix in editor'))
expect(onToggle).toHaveBeenCalledOnce()
})
it('still shows backlinks and history for notes without frontmatter', () => {
render(
<Inspector
{...defaultProps}
entry={noFrontmatterEntry}
content="# Plain note"
entries={[noFrontmatterEntry, { ...referrerEntry, outgoingLinks: ['plain-note'] }]}
gitHistory={mockGitHistory}
onInitializeProperties={vi.fn()}
/>
)
expect(screen.getByText('Initialize properties')).toBeInTheDocument()
expect(screen.getByText('Backlinks')).toBeInTheDocument()
expect(screen.getByText('History')).toBeInTheDocument()
})
})
})

View File

@@ -2,9 +2,9 @@ import { useMemo, useCallback } from 'react'
import { useDragRegion } from '../hooks/useDragRegion'
import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import { SlidersHorizontal, X } from '@phosphor-icons/react'
import { SlidersHorizontal, X, Sparkle, WarningCircle, PencilSimple } from '@phosphor-icons/react'
import { Separator } from './ui/separator'
import { parseFrontmatter } from '../utils/frontmatter'
import { parseFrontmatter, detectFrontmatterState } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { wikilinkTarget } from '../utils/wikilink'
@@ -25,6 +25,17 @@ interface InspectorProps {
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onCreateAndOpenNote?: (title: string) => Promise<boolean>
onInitializeProperties?: (path: string) => void
onToggleRawEditor?: () => void
}
function targetMatchesEntry(target: string, entryPath: string, matchTargets: Set<string>): boolean {
if (matchTargets.has(target)) return true
const lastSegment = target.split('/').pop() ?? ''
if (matchTargets.has(lastSegment)) return true
// Path-suffix match: "projects/alpha" matches entry path ending with "/projects/alpha.md"
if (target.includes('/') && entryPath.toLowerCase().endsWith('/' + target.toLowerCase() + '.md')) return true
return false
}
function useBacklinks(
@@ -37,7 +48,6 @@ function useBacklinks(
const matchTargets = new Set([
entry.title, ...entry.aliases,
entry.filename.replace(/\.md$/, ''),
entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, ''),
])
const referencedByPaths = new Set(referencedBy.map((item) => item.entry.path))
@@ -46,9 +56,7 @@ function useBacklinks(
.filter((e) => {
if (e.path === entry.path) return false
if (referencedByPaths.has(e.path)) return false
return e.outgoingLinks.some((target) =>
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
)
return e.outgoingLinks.some((target) => targetMatchesEntry(target, entry.path, matchTargets))
})
.map((e) => ({
entry: e,
@@ -68,9 +76,8 @@ function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): Refer
return useMemo(() => {
if (!entry) return []
const pathStem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
const filenameStem = entry.filename.replace(/\.md$/, '')
const matchTargets = new Set([pathStem, filenameStem, entry.title, ...entry.aliases])
const matchTargets = new Set([filenameStem, entry.title, ...entry.aliases])
const results: ReferencedByItem[] = []
@@ -114,13 +121,46 @@ function EmptyInspector() {
)
}
function InitializePropertiesPrompt({ onClick }: { onClick: () => void }) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border px-4 py-6">
<Sparkle size={24} className="text-muted-foreground" />
<p className="m-0 text-center text-[13px] text-muted-foreground">This note has no properties yet</p>
<button
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={onClick}
>
Initialize properties
</button>
</div>
)
}
function InvalidFrontmatterNotice({ onFix }: { onFix: () => void }) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-6">
<WarningCircle size={24} className="text-destructive" />
<p className="m-0 text-center text-[13px] text-muted-foreground">Invalid properties</p>
<button
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
onClick={onFix}
>
<PencilSimple size={14} />
Fix in editor
</button>
</div>
)
}
export function Inspector({
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
onInitializeProperties, onToggleRawEditor,
}: InspectorProps) {
const referencedBy = useReferencedBy(entry, entries)
const backlinks = useBacklinks(entry, entries, referencedBy)
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
const fmState = useMemo(() => detectFrontmatterState(content), [content])
const typeEntryMap = useMemo(() => {
const map: Record<string, VaultEntry> = {}
for (const e of entries) { if (e.isA === 'Type') map[e.title] = e }
@@ -146,23 +186,31 @@ export function Inspector({
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
{entry ? (
<>
<DynamicPropertiesPanel
entry={entry} content={content} frontmatter={frontmatter}
entries={entries}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onNavigate={onNavigate}
/>
<DynamicRelationshipsPanel
frontmatter={frontmatter} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
{fmState === 'valid' ? (
<>
<DynamicPropertiesPanel
entry={entry} content={content} frontmatter={frontmatter}
entries={entries}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onNavigate={onNavigate}
/>
<DynamicRelationshipsPanel
frontmatter={frontmatter} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
onAddProperty={onAddProperty ? handleAddProperty : undefined}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
</>
) : fmState === 'invalid' ? (
onToggleRawEditor && <InvalidFrontmatterNotice onFix={onToggleRawEditor} />
) : (
onInitializeProperties && <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} />
)}
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
{gitHistory.length > 0 && <Separator />}

View File

@@ -129,6 +129,9 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
{entry.title}
<StateBadge archived={entry.archived} trashed={entry.trashed} />
</div>
{entry.path.includes('/') && (
<div className="truncate text-[10px] text-muted-foreground" data-testid="note-path">{entry.path}</div>
)}
</div>
{entry.snippet && (
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>

View File

@@ -208,6 +208,20 @@ describe('NoteList', () => {
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('passes selected type when creating note from type section', () => {
const onCreate = vi.fn()
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={onCreate} />)
fireEvent.click(screen.getByTitle('Create new note'))
expect(onCreate).toHaveBeenCalledWith('Project')
})
it('passes undefined type when creating note from All Notes', () => {
const onCreate = vi.fn()
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={onCreate} />)
fireEvent.click(screen.getByTitle('Create new note'))
expect(onCreate).toHaveBeenCalledWith(undefined)
})
it('shows entity pinned at top with grouped children', () => {
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />

View File

@@ -32,7 +32,7 @@ interface NoteListProps {
sidebarCollapsed?: boolean
onSelectNote: (entry: VaultEntry) => void
onReplaceActiveTab: (entry: VaultEntry) => void
onCreateNote: () => void
onCreateNote: (type?: string) => void
onBulkArchive?: (paths: string[]) => void
onBulkTrash?: (paths: string[]) => void
onBulkRestore?: (paths: string[]) => void
@@ -47,14 +47,15 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const isSectionGroup = selection.kind === 'sectionGroup'
const isFolderView = selection.kind === 'folder'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
const showFilterPills = isSectionGroup || isAllNotesView
const showFilterPills = isSectionGroup || isFolderView || isAllNotesView
const subFilter = showFilterPills ? noteListFilter : undefined
const filterCounts = useMemo(
() => isSectionGroup ? countByFilter(entries, selection.type) : isAllNotesView ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
[entries, isSectionGroup, isAllNotesView, selection],
() => isSectionGroup ? countByFilter(entries, selection.type) : (isAllNotesView || isFolderView) ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
[entries, isSectionGroup, isAllNotesView, isFolderView, selection],
)
const inboxCounts = useMemo(
@@ -96,15 +97,16 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
const handleCreateNote = useCallback(() => {
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
}, [onCreateNote, selection])
const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
const title = resolveHeaderTitle(selection, typeDocument)
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} />}
<div className="flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{entitySelection ? (
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
@@ -113,6 +115,8 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
)}
</div>
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} position="bottom" />}
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} position="bottom" />}
</div>
{multiSelect.isMultiSelecting && (
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />

View File

@@ -42,7 +42,7 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
return (
<span className="relative inline-flex min-w-0 items-center">
<span
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium transition-opacity hover:opacity-80"
className="inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-md px-2 text-[12px] font-medium transition-opacity hover:opacity-80"
style={{ backgroundColor: style.bg, color: style.color }}
onClick={() => onStartEdit(propKey)}
data-testid="status-badge"
@@ -83,8 +83,8 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
return (
<span
key={tag}
className="group/tag relative inline-flex items-center overflow-hidden rounded-md"
style={{ backgroundColor: style.bg, padding: '4px 8px', maxWidth: 120 }}
className="group/tag relative inline-flex h-6 items-center overflow-hidden rounded-md"
style={{ backgroundColor: style.bg, padding: '0 8px', maxWidth: 120 }}
>
<span
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
@@ -110,8 +110,8 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
)
})}
<button
className="inline-flex shrink-0 items-center justify-center rounded-md border-none bg-muted text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ padding: '4px 8px' }}
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border-none bg-muted text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ padding: '0 8px' }}
onClick={() => onStartEdit(propKey)}
title="Add tag"
data-testid="tags-add-button"
@@ -130,7 +130,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
return (
<label className="inline-flex cursor-pointer items-center gap-1.5" data-testid="boolean-toggle">
<label className="inline-flex h-6 cursor-pointer items-center gap-1.5" data-testid="boolean-toggle">
<input
type="checkbox"
checked={value}
@@ -164,7 +164,7 @@ function DateValue({ value, onSave }: {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className={`inline-flex min-w-0 cursor-pointer items-center gap-1 border-none px-2 py-1 text-right text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded-md bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 border-none px-2 text-right text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded-md bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
title={value}
data-testid="date-display"
>

View File

@@ -21,6 +21,7 @@ export interface RawEditorViewProps {
path: string
entries: VaultEntry[]
onContentChange: (path: string, content: string) => void
vaultPath?: string
onSave: () => void
/** Mutable ref updated on every keystroke with the latest doc string.
* Allows the parent to flush debounced content before unmount. */
@@ -37,7 +38,7 @@ function getCursorCoords(view: EditorView): { top: number; left: number } | null
return { top: coords.bottom, left: coords.left }
}
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef }: RawEditorViewProps) {
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pathRef = useRef(path)
@@ -92,10 +93,10 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
const coords = getCursorCoords(view)
if (!coords) { setAutocomplete(null); return }
const candidates = preFilterWikilinks(baseItems, query)
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title))
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title), vaultPath ?? '')
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
}, [baseItems, typeEntryMap])
}, [baseItems, typeEntryMap, vaultPath])
const handleSave = useCallback(() => {
if (debounceRef.current) {

View File

@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { RenameDetectedBanner } from './RenameDetectedBanner'
describe('RenameDetectedBanner', () => {
const renames = [
{ old_path: 'old-note.md', new_path: 'new-note.md' },
{ old_path: 'folder/draft.md', new_path: 'folder/published.md' },
]
it('renders nothing when renames is empty', () => {
const { container } = render(
<RenameDetectedBanner renames={[]} onUpdate={vi.fn()} onDismiss={vi.fn()} />
)
expect(container.firstChild).toBeNull()
})
it('shows banner with rename count', () => {
render(<RenameDetectedBanner renames={renames} onUpdate={vi.fn()} onDismiss={vi.fn()} />)
expect(screen.getByText(/2 files? renamed/i)).toBeInTheDocument()
})
it('shows Update wikilinks button', () => {
render(<RenameDetectedBanner renames={renames} onUpdate={vi.fn()} onDismiss={vi.fn()} />)
expect(screen.getByText('Update wikilinks')).toBeInTheDocument()
})
it('calls onUpdate when Update button clicked', () => {
const onUpdate = vi.fn()
render(<RenameDetectedBanner renames={renames} onUpdate={onUpdate} onDismiss={vi.fn()} />)
fireEvent.click(screen.getByText('Update wikilinks'))
expect(onUpdate).toHaveBeenCalledOnce()
})
it('calls onDismiss when Ignore button clicked', () => {
const onDismiss = vi.fn()
render(<RenameDetectedBanner renames={renames} onUpdate={vi.fn()} onDismiss={onDismiss} />)
fireEvent.click(screen.getByText('Ignore'))
expect(onDismiss).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,38 @@
import { ArrowsClockwise } from '@phosphor-icons/react'
export interface DetectedRename {
old_path: string
new_path: string
}
interface RenameDetectedBannerProps {
renames: DetectedRename[]
onUpdate: () => void
onDismiss: () => void
}
export function RenameDetectedBanner({ renames, onUpdate, onDismiss }: RenameDetectedBannerProps) {
if (renames.length === 0) return null
const count = renames.length
return (
<div className="flex items-center gap-3 border-b border-border bg-accent/50 px-4 py-2 text-[13px]">
<ArrowsClockwise size={16} className="shrink-0 text-accent-foreground" />
<span className="flex-1 text-foreground">
{count} file{count !== 1 ? 's' : ''} renamed outside Laputa. Update wikilinks?
</span>
<button
className="shrink-0 cursor-pointer rounded-md bg-primary px-3 py-1 text-[12px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
onClick={onUpdate}
>
Update wikilinks
</button>
<button
className="shrink-0 cursor-pointer rounded-md border border-border bg-transparent px-3 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted"
onClick={onDismiss}
>
Ignore
</button>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import type { VaultEntry, FolderNode, SidebarSelection } from '../types'
import { buildTypeEntryMap } from '../utils/typeColors'
import { buildDynamicSections, sortSections } from '../utils/sidebarSections'
import { TypeCustomizePopover } from './TypeCustomizePopover'
@@ -20,6 +20,7 @@ import {
NavItem, SectionContent, type SectionContentProps, VisibilityPopover,
} from './SidebarParts'
import { useDragRegion } from '../hooks/useDragRegion'
import { FolderTree } from './FolderTree'
interface SidebarProps {
entries: VaultEntry[]
@@ -33,6 +34,8 @@ interface SidebarProps {
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
onRenameSection?: (typeName: string, label: string) => void
onToggleTypeVisibility?: (typeName: string) => void
folders?: FolderNode[]
onCreateFolder?: (name: string) => void
inboxCount?: number
onCollapse?: () => void
}
@@ -205,7 +208,7 @@ export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
onToggleTypeVisibility,
inboxCount = 0, onCollapse,
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -288,10 +291,10 @@ export const Sidebar = memo(function Sidebar({
<nav className="flex-1 overflow-y-auto">
{/* Top nav */}
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-destructive text-destructive-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
</div>
{/* Sections header + visibility popover */}
@@ -313,6 +316,9 @@ export const Sidebar = memo(function Sidebar({
))}
</SortableContext>
</DndContext>
{/* Folder tree */}
<FolderTree folders={folders} selection={selection} onSelect={onSelect} onCreateFolder={onCreateFolder} />
</nav>
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />

View File

@@ -19,6 +19,7 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
switch (check.kind) {
case 'filter': return (current as typeof check).filter === check.filter
case 'sectionGroup': return (current as typeof check).type === check.type
case 'folder': return (current as typeof check).path === check.path
case 'entity': return (current as typeof check).entry.path === check.entry.path
default: return false
}
@@ -26,7 +27,7 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
// --- NavItem ---
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled, disabledTooltip, compact }: {
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
icon: ComponentType<IconProps>
label: string
count?: number
@@ -34,6 +35,8 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
activeClassName?: string
badgeClassName?: string
badgeStyle?: React.CSSProperties
activeBadgeClassName?: string
activeBadgeStyle?: React.CSSProperties
onClick?: () => void
disabled?: boolean
disabledTooltip?: string
@@ -42,6 +45,8 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
const iconSize = compact ? 14 : 16
const textClass = compact ? 'text-[12px]' : 'text-[13px]'
const padding = compact ? '4px 16px' : '6px 16px'
const resolvedBadgeClass = isActive && activeBadgeClassName ? activeBadgeClassName : badgeClassName
const resolvedBadgeStyle = isActive && activeBadgeClassName ? activeBadgeStyle : badgeStyle
if (disabled) {
return (
@@ -57,10 +62,10 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
style={{ padding, borderRadius: 4 }}
onClick={onClick}
>
<Icon size={iconSize} />
<Icon size={iconSize} weight={isActive ? 'fill' : 'regular'} />
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
{count !== undefined && count > 0 && (
<span className={cn("flex items-center justify-center", badgeClassName)} style={{ height: compact ? 18 : 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...badgeStyle }}>
<span className={cn("flex items-center justify-center", resolvedBadgeClass)} style={{ height: compact ? 18 : 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...resolvedBadgeStyle }}>
{count}
</span>
)}

View File

@@ -92,16 +92,16 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < MIN_QUERY_LENGTH) return []
const candidates = preFilterWikilinks(baseItems, query)
const items = attachClickHandlers(candidates, insertWikilink)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, query, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap])
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < PERSON_MENTION_MIN_QUERY) return []
const candidates = filterPersonMentions(baseItems, query)
const items = attachClickHandlers(candidates, insertWikilink)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, query, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap])
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
return (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>

View File

@@ -334,7 +334,7 @@ describe('StatusBar', () => {
expect(onClickPulse).not.toHaveBeenCalled()
})
it('shows Commit & Push button next to Changes badge', () => {
it('shows Commit button in status bar', () => {
const onCommitPush = vi.fn()
render(<StatusBar noteCount={100} modifiedCount={5} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={onCommitPush} />)
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()
@@ -342,8 +342,13 @@ describe('StatusBar', () => {
expect(onCommitPush).toHaveBeenCalledOnce()
})
it('hides Commit & Push button when no modified files', () => {
it('shows Commit button even when no modified files', () => {
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={vi.fn()} />)
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()
})
it('hides Commit button when no onCommitPush callback', () => {
render(<StatusBar noteCount={100} modifiedCount={5} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
})

View File

@@ -332,7 +332,7 @@ function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void
)
}
function ChangesBadge({ count, onClick, onCommitPush }: { count: number; onClick?: () => void; onCommitPush?: () => void }) {
function ChangesBadge({ count, onClick }: { count: number; onClick?: () => void }) {
if (count <= 0) return null
return (
<>
@@ -350,19 +350,27 @@ function ChangesBadge({ count, onClick, onCommitPush }: { count: number; onClick
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: 'var(--accent-orange)', color: '#fff', borderRadius: 9, padding: '0 5px', fontSize: 10, fontWeight: 600, minWidth: 16, lineHeight: '16px' }}>{count}</span>
Changes
</span>
{onCommitPush && (
<span
role="button"
onClick={onCommitPush}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Commit & Push"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-commit-push"
>
<GitCommitHorizontal size={13} style={{ color: 'var(--accent-orange)' }} />
</span>
)}
</>
)
}
function CommitButton({ onClick }: { onClick?: () => void }) {
if (!onClick) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Commit & Push"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="status-commit-push"
>
<GitCommitHorizontal size={13} />
Commit
</span>
</>
)
}
@@ -436,8 +444,8 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
}, [])
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)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<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)', position: 'relative', zIndex: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} onRemoveVault={onRemoveVault} />
<span style={SEP_STYLE}>|</span>
<span
@@ -449,15 +457,15 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
onMouseEnter={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
><Package size={13} />{buildNumber ?? 'b?'}</span>
<span style={SEP_STYLE}>|</span>
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
<CommitButton onClick={onCommitPush} />
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} remoteStatus={remoteStatus} onTriggerSync={onTriggerSync} onPullAndPush={onPullAndPush} onOpenConflictResolver={onOpenConflictResolver} />
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<ChangesBadge count={modifiedCount} onClick={onClickPending} onCommitPush={onCommitPush} />
<PulseBadge onClick={onClickPulse} disabled={!isGitVault} />
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
{zoomLevel !== 100 && (
<span

View File

@@ -28,7 +28,7 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
{onNavigate ? (
<button
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '0 8px', fontSize: 12, fontWeight: 500, height: 24, display: 'inline-flex', alignItems: 'center' }}
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
>{isA}</button>
) : (
@@ -68,7 +68,8 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
background: typeLightColor ?? undefined,
color: typeColor ?? undefined,
borderRadius: 6,
padding: '4px 8px',
padding: '0 8px',
height: 24,
fontSize: 12,
fontWeight: 500,
}}

View File

@@ -5,6 +5,7 @@ interface FilterPillsProps {
active: NoteListFilter
counts: Record<NoteListFilter, number>
onChange: (filter: NoteListFilter) => void
position?: 'top' | 'bottom'
}
const PILLS: { value: NoteListFilter; label: string }[] = [
@@ -13,9 +14,18 @@ const PILLS: { value: NoteListFilter; label: string }[] = [
{ value: 'trashed', label: 'Trashed' },
]
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)'
function FilterPillsInner({ active, counts, onChange, position = 'top' }: FilterPillsProps) {
const isBottom = position === 'bottom'
return (
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="filter-pills">
<div
className={isBottom
? 'absolute bottom-0 left-0 right-0 z-10 flex flex-wrap items-center justify-center gap-2 px-4 py-3'
: 'flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5'}
style={isBottom ? { background: BOTTOM_GRADIENT } : undefined}
data-testid="filter-pills"
>
{PILLS.map(({ value, label }) => (
<button
key={value}

View File

@@ -5,6 +5,7 @@ interface InboxFilterPillsProps {
active: InboxPeriod
counts: Record<InboxPeriod, number>
onChange: (period: InboxPeriod) => void
position?: 'top' | 'bottom'
}
const PILLS: { value: InboxPeriod; label: string }[] = [
@@ -14,9 +15,18 @@ const PILLS: { value: InboxPeriod; label: string }[] = [
{ value: 'all', label: 'All' },
]
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)'
function InboxFilterPillsInner({ active, counts, onChange, position = 'top' }: InboxFilterPillsProps) {
const isBottom = position === 'bottom'
return (
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="inbox-filter-pills">
<div
className={isBottom
? 'absolute bottom-0 left-0 right-0 z-10 flex flex-wrap items-center justify-center gap-2 px-4 py-3'
: 'flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5'}
style={isBottom ? { background: BOTTOM_GRADIENT } : undefined}
data-testid="inbox-filter-pills"
>
{PILLS.map(({ value, label }) => (
<button
key={value}

View File

@@ -39,22 +39,6 @@ describe('frontmatterHighlightPlugin', () => {
parent.remove()
})
it('applies heading class to markdown headings', () => {
const { view, parent } = createView('# Heading One\n\nSome text\n\n## Heading Two')
const headings = parent.querySelectorAll('.cm-md-heading')
expect(headings.length).toBeGreaterThanOrEqual(2)
view.destroy()
parent.remove()
})
it('does not apply heading class to plain text', () => {
const { view, parent } = createView('Just some plain text\nAnother line')
const headings = parent.querySelectorAll('.cm-md-heading')
expect(headings.length).toBe(0)
view.destroy()
parent.remove()
})
it('handles content without frontmatter', () => {
const { view, parent } = createView('# Just a heading\n\nNo frontmatter here.')
const delimiters = parent.querySelectorAll('.cm-frontmatter-delimiter')

View File

@@ -4,7 +4,6 @@ import { RangeSetBuilder } from '@codemirror/state'
const frontmatterDelimiter = Decoration.mark({ class: 'cm-frontmatter-delimiter' })
const frontmatterKey = Decoration.mark({ class: 'cm-frontmatter-key' })
const frontmatterValue = Decoration.mark({ class: 'cm-frontmatter-value' })
const markdownHeading = Decoration.mark({ class: 'cm-md-heading' })
function findFrontmatterEnd(doc: { lines: number; line(n: number): { text: string } }): number {
if (doc.lines < 1) return -1
@@ -27,8 +26,6 @@ function buildDecorations(view: EditorView): DecorationSet {
if (i <= fmEnd) {
decorateFrontmatterLine(builder, line.from, text, i === 1 || i === fmEnd)
} else {
decorateMarkdownLine(builder, line.from, text)
}
}
@@ -60,16 +57,6 @@ function decorateFrontmatterLine(
}
}
function decorateMarkdownLine(
builder: RangeSetBuilder<Decoration>,
from: number,
text: string,
): void {
if (/^#{1,6}\s/.test(text)) {
builder.add(from, from + text.length, markdownHeading)
}
}
export const frontmatterHighlightPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet
@@ -89,12 +76,9 @@ export function frontmatterHighlightTheme() {
const keyColor = '#c9383e'
const valueColor = '#2a7e4f'
const delimiterColor = '#c9383e'
const headingColor = '#0969da'
return EditorView.baseTheme({
'.cm-frontmatter-delimiter': { color: delimiterColor, fontWeight: '600' },
'.cm-frontmatter-key': { color: keyColor },
'.cm-frontmatter-value': { color: valueColor },
'.cm-md-heading': { color: headingColor, fontWeight: '600' },
})
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { markdownLanguage } from './markdownHighlight'
function createView(doc: string) {
const parent = document.createElement('div')
document.body.appendChild(parent)
const state = EditorState.create({
doc,
extensions: [markdownLanguage()],
})
const view = new EditorView({ state, parent })
return { view, parent }
}
describe('markdownLanguage', () => {
it('returns a valid extension', () => {
const ext = markdownLanguage()
expect(ext).toBeDefined()
expect(Array.isArray(ext)).toBe(true)
})
it('creates an editor without errors', () => {
const { view, parent } = createView('# Heading\n\n**bold** and *italic*\n\n- list item')
expect(view.state.doc.toString()).toContain('# Heading')
view.destroy()
parent.remove()
})
it('parses markdown content with mixed syntax', () => {
const doc = [
'# Title',
'',
'Some **bold** and *italic* text.',
'',
'- item one',
'- item two',
'',
'[a link](http://example.com)',
'',
'> a blockquote',
'',
'`inline code`',
].join('\n')
const { view, parent } = createView(doc)
expect(view.state.doc.lines).toBe(12)
view.destroy()
parent.remove()
})
})

View File

@@ -0,0 +1,28 @@
import { markdown } from '@codemirror/lang-markdown'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
import type { Extension } from '@codemirror/state'
const markdownHighlightStyle = HighlightStyle.define([
{ tag: tags.heading1, color: '#0969da', fontWeight: '700', fontSize: '1.4em' },
{ tag: tags.heading2, color: '#0969da', fontWeight: '700', fontSize: '1.25em' },
{ tag: tags.heading3, color: '#0969da', fontWeight: '600', fontSize: '1.1em' },
{ tag: tags.heading4, color: '#0969da', fontWeight: '600' },
{ tag: tags.heading5, color: '#0969da', fontWeight: '600' },
{ tag: tags.heading6, color: '#0969da', fontWeight: '600' },
{ tag: tags.strong, fontWeight: '700' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{ tag: tags.link, color: '#0969da', textDecoration: 'underline' },
{ tag: tags.url, color: '#0969da' },
{ tag: tags.monospace, color: '#c9383e', backgroundColor: 'rgba(175,184,193,0.15)', borderRadius: '3px' },
{ tag: tags.list, color: '#c9383e' },
{ tag: tags.quote, color: '#636c76', fontStyle: 'italic' },
{ tag: tags.separator, color: '#636c76' },
{ tag: tags.processingInstruction, color: '#c9383e', fontWeight: '600' },
{ tag: tags.contentSeparator, color: '#c9383e', fontWeight: '600' },
])
export function markdownLanguage(): Extension {
return [markdown(), syntaxHighlighting(markdownHighlightStyle)]
}

View File

@@ -29,7 +29,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, 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 },

View File

@@ -4,6 +4,7 @@ import type { VaultEntry } from '../types'
import type { FrontmatterValue } from '../components/Inspector'
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
import { updateMockContent, trackMockChange } from '../mock-tauri'
import { parseFrontmatter } from '../utils/frontmatter'
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
@@ -65,6 +66,17 @@ export function frontmatterToEntryPatch(
return { patch: updates[k] ?? {}, relationshipPatch }
}
/** Parse frontmatter from full content and return a merged VaultEntry patch for all known fields. */
export function contentToEntryPatch(content: string): Partial<VaultEntry> {
const fm = parseFrontmatter(content)
const merged: Partial<VaultEntry> = {}
for (const [key, value] of Object.entries(fm)) {
const { patch } = frontmatterToEntryPatch('update', key, value)
Object.assign(merged, patch)
}
return merged
}
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
return invoke<string>(command, args)
}

View File

@@ -175,24 +175,32 @@ describe('useAppKeyboard', () => {
expect(actions.onZoomReset).toHaveBeenCalled()
})
it('Cmd+I triggers toggle AI chat', () => {
it('Cmd+Option+I triggers toggle AI chat', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('i', { metaKey: true })
fireKey('i', { metaKey: true, altKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
it('Cmd+I works when text input is focused', () => {
it('Cmd+Option+I works when text input is focused', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
withFocusedInput(() => {
fireKey('i', { metaKey: true })
fireKey('i', { metaKey: true, altKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
})
})
it('Cmd+I does not trigger AI chat (reserved for italic)', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
fireKey('i', { metaKey: true })
expect(onToggleAIChat).not.toHaveBeenCalled()
})
it('Cmd+Shift+O triggers open in new window', () => {
const actions = makeActions()
const onOpenInNewWindow = vi.fn()
@@ -218,4 +226,14 @@ describe('useAppKeyboard', () => {
expect(onToggleInspector).toHaveBeenCalled()
expect(onToggleAIChat).not.toHaveBeenCalled()
})
it('Cmd+Option+I does not trigger inspector toggle', () => {
const actions = makeActions()
const onToggleAIChat = vi.fn()
const onToggleInspector = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat, onToggleInspector }))
fireKey('i', { metaKey: true, altKey: true })
expect(onToggleAIChat).toHaveBeenCalled()
expect(onToggleInspector).not.toHaveBeenCalled()
})
})

View File

@@ -89,11 +89,16 @@ export function useAppKeyboard({
'+': onZoomIn,
'-': onZoomOut,
'0': onZoomReset,
i: () => onToggleAIChat?.(),
'\\': () => onToggleRawEditor?.(),
}
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd+Option+I: toggle AI panel
if ((e.metaKey || e.ctrlKey) && e.altKey && !e.shiftKey && (e.key === 'i' || e.key === 'I' || e.key === 'ˆ')) {
e.preventDefault()
onToggleAIChat?.()
return
}
// Cmd+Shift+F: full-text search (distinct from Cmd+F browser find)
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') {
e.preventDefault()

View File

@@ -3,9 +3,10 @@ import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirro
import { EditorState } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight'
import { markdownLanguage } from '../extensions/markdownHighlight'
import { zoomCursorFix } from '../extensions/zoomCursorFix'
const FONT_FAMILY = '"Berkeley Mono", "JetBrains Mono", "Fira Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
export interface CodeMirrorCallbacks {
onDocChange: (doc: string) => void
@@ -109,6 +110,7 @@ export function useCodeMirror(
keymap.of([...defaultKeymap, ...historyKeymap]),
buildSaveKeymap(callbacksRef),
buildBaseTheme(),
markdownLanguage(),
frontmatterHighlightTheme(),
frontmatterHighlightPlugin,
zoomCursorFix(),

View File

@@ -125,6 +125,42 @@ describe('useEditorSaveWithLinks', () => {
})
})
it('handleContentChange calls updateEntry with frontmatter patch when type changes', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/note.md', '---\ntype: Project\nstatus: Active\n---\nBody')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Project', status: 'Active' })
})
it('handleContentChange does NOT call updateEntry for frontmatter when unchanged', () => {
const { result } = renderHookWithLinks()
const content = '---\ntype: Essay\n---\nBody text'
act(() => { result.current.handleContentChange('/note.md', content) })
const callCount = updateEntry.mock.calls.length
act(() => { result.current.handleContentChange('/note.md', content + ' more') })
// Same frontmatter, only body changed — no extra updateEntry for frontmatter
expect(updateEntry).toHaveBeenCalledTimes(callCount)
})
it('handleContentChange updates entry when type changes in frontmatter', () => {
const { result } = renderHookWithLinks()
act(() => {
result.current.handleContentChange('/note.md', '---\ntype: Essay\n---\nBody')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Essay' })
act(() => {
result.current.handleContentChange('/note.md', '---\ntype: Note\n---\nBody')
})
expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Note' })
})
it('spreads all properties from useEditorSave onto the return value', () => {
const { result } = renderHookWithLinks()

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef } from 'react'
import { useEditorSave } from './useEditorSave'
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
import { contentToEntryPatch } from './frontmatterOps'
import type { VaultEntry } from '../types'
export function useEditorSaveWithLinks(config: {
@@ -21,6 +22,7 @@ export function useEditorSaveWithLinks(config: {
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 prevFmKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
rawOnChange(path, content)
const links = extractOutgoingLinks(content)
@@ -29,6 +31,12 @@ export function useEditorSaveWithLinks(config: {
prevLinksKeyRef.current = key
updateEntry(path, { outgoingLinks: links })
}
const fmPatch = contentToEntryPatch(content)
const fmKey = JSON.stringify(fmPatch)
if (fmKey !== prevFmKeyRef.current) {
prevFmKeyRef.current = fmKey
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
}
}, [rawOnChange, updateEntry])
return { ...editor, handleContentChange }
}

View File

@@ -1,71 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
interface HealthReport {
stray_files: string[]
title_mismatches: { path: string; filename: string; title: string; expected_filename: string }[]
}
interface FlatVaultMigration {
/** True if stray files were detected in non-protected subfolders. */
needsMigration: boolean
/** List of stray file paths (relative to vault root). */
strayFiles: string[]
/** Dismiss the migration prompt without migrating. */
dismiss: () => void
/** Run flatten_vault and reload. Returns the count of files moved. */
migrate: () => Promise<number>
/** True while migration is running. */
isMigrating: boolean
}
/**
* Detects if the vault has files in non-protected subfolders and offers
* to flatten them to the vault root. Runs once on vault load.
*/
export function useFlatVaultMigration(
vaultPath: string,
entriesLoaded: boolean,
reloadVault: () => Promise<unknown>,
): FlatVaultMigration {
const [strayFiles, setStrayFiles] = useState<string[]>([])
const [dismissed, setDismissed] = useState(false)
const [isMigrating, setIsMigrating] = useState(false)
useEffect(() => {
if (!entriesLoaded || !vaultPath || !isTauri()) return
let cancelled = false
invoke<HealthReport>('vault_health_check', { vaultPath })
.then((report) => {
if (!cancelled && report.stray_files.length > 0) {
setStrayFiles(report.stray_files)
}
})
.catch(() => { /* non-critical */ })
return () => { cancelled = true }
}, [vaultPath, entriesLoaded])
const dismiss = useCallback(() => setDismissed(true), [])
const migrate = useCallback(async () => {
setIsMigrating(true)
try {
const count = await invoke<number>('flatten_vault', { vaultPath })
setStrayFiles([])
setDismissed(true)
await reloadVault()
return count
} finally {
setIsMigrating(false)
}
}, [vaultPath, reloadVault])
return {
needsMigration: strayFiles.length > 0 && !dismissed,
strayFiles,
dismiss,
migrate,
isMigrating,
}
}

View File

@@ -19,7 +19,7 @@ import {
resolveTemplate,
} from './useNoteCreation'
import { needsRenameOnSave } from './useNoteRename'
import { frontmatterToEntryPatch, applyRelationshipPatch } from './frontmatterOps'
import { frontmatterToEntryPatch, applyRelationshipPatch, contentToEntryPatch } from './frontmatterOps'
import { useNoteActions } from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -440,6 +440,36 @@ describe('applyRelationshipPatch', () => {
})
})
describe('contentToEntryPatch', () => {
it('extracts type from frontmatter', () => {
const content = '---\ntype: Project\nstatus: Active\n---\nBody text'
expect(contentToEntryPatch(content)).toEqual({ isA: 'Project', status: 'Active' })
})
it('returns empty patch when no frontmatter', () => {
expect(contentToEntryPatch('Just a body')).toEqual({})
})
it('returns empty patch for empty content', () => {
expect(contentToEntryPatch('')).toEqual({})
})
it('extracts color, icon, and aliases', () => {
const content = '---\ncolor: red\nicon: star\naliases:\n - Foo\n - Bar\n---\n'
expect(contentToEntryPatch(content)).toEqual({ color: 'red', icon: 'star', aliases: ['Foo', 'Bar'] })
})
it('handles is_a as alias for type', () => {
const content = '---\nis_a: Essay\n---\n'
expect(contentToEntryPatch(content)).toEqual({ isA: 'Essay' })
})
it('ignores unknown frontmatter keys', () => {
const content = '---\ntype: Note\ncustom: value\n---\n'
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note' })
})
})
describe('todayDateString', () => {
it('returns date in YYYY-MM-DD format', () => {
const result = todayDateString()

View File

@@ -257,7 +257,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
}, type)
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending, setToastMessage])
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending])
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
createNoteForRelationship({

View File

@@ -128,6 +128,15 @@ describe('useVaultLoader', () => {
expect(result.current.entries[0].archived).toBe(true)
expect(result.current.entries[0].status).toBe('Done')
})
it('preserves entries reference when path does not exist (no-op)', async () => {
const { result } = await renderVaultLoader()
const entriesBefore = result.current.entries
act(() => { result.current.updateEntry('/vault/note/nonexistent.md', { archived: true }) })
expect(result.current.entries).toBe(entriesBefore)
})
})
describe('getNoteStatus', () => {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState, startTransition } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus, GitPushResult } from '../types'
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult } from '../types'
import { clearPrefetchCache } from './useTabManagement'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
@@ -89,6 +89,7 @@ export function resolveNoteStatus(
export function useVaultLoader(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [folders, setFolders] = useState<FolderNode[]>([])
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
const [modifiedFilesError, setModifiedFilesError] = useState<string | null>(null)
const tracker = useNewNoteTracker()
@@ -97,10 +98,13 @@ export function useVaultLoader(vaultPath: string) {
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
setEntries([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
setEntries([]); setFolders([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
loadVaultData(vaultPath)
.then(({ entries: e }) => { setEntries(e) })
.catch((err) => console.warn('Vault scan failed:', err))
tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
.then((f) => { setFolders(f ?? []) })
.catch(() => { /* folders are optional — ignore errors */ })
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable
const loadModifiedFiles = useCallback(async () => {
@@ -129,8 +133,16 @@ export function useVaultLoader(vaultPath: string) {
})
}, [tracker])
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) =>
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e)), [])
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
setEntries((prev) => {
let changed = false
const next = prev.map((e) => {
if (e.path === path) { changed = true; return { ...e, ...patch } }
return e
})
return changed ? next : prev
})
}, [])
const removeEntry = useCallback((path: string) => {
setEntries((prev) => prev.filter((e) => e.path !== path))
@@ -168,7 +180,7 @@ export function useVaultLoader(vaultPath: string) {
)
return {
entries, modifiedFiles, modifiedFilesError,
entries, folders, modifiedFiles, modifiedFilesError,
addEntry, updateEntry, removeEntry, replaceEntry,
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
getNoteStatus, commitAndPush, reloadVault,

View File

@@ -143,9 +143,13 @@
padding: 0;
overflow: hidden;
}
html, body {
height: 100%;
width: 100%;
}
#root {
width: 100vw;
height: 100vh;
width: 100%;
height: 100%;
}
}

View File

@@ -135,6 +135,7 @@ const trimOrNull = (v: string | null | undefined): string | null => v?.trim() ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
export const mockHandlers: Record<string, (args: any) => any> = {
list_vault: () => MOCK_ENTRIES,
list_vault_folders: () => [],
reload_vault: () => MOCK_ENTRIES,
reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, trashed: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {} },
sync_note_title: () => false,

View File

@@ -178,4 +178,12 @@ export type InboxPeriod = 'week' | 'month' | 'quarter' | 'all'
export type SidebarSelection =
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string }
| { kind: 'folder'; path: string }
| { kind: 'entity'; entry: VaultEntry }
/** A node in the vault's folder tree (directories only, no files). */
export interface FolderNode {
name: string
path: string
children: FolderNode[]
}

View File

@@ -4,7 +4,7 @@ import { compactMarkdown } from './compact-markdown'
describe('compactMarkdown', () => {
it('collapses blank lines between bullet list items (tight list)', () => {
const input = '* Item one\n\n* Item two\n\n* Item three\n'
expect(compactMarkdown(input)).toBe('* Item one\n* Item two\n* Item three\n')
expect(compactMarkdown(input)).toBe('- Item one\n- Item two\n- Item three\n')
})
it('collapses blank lines between dash list items', () => {
@@ -19,7 +19,7 @@ describe('compactMarkdown', () => {
it('removes extra blank line after heading', () => {
const input = '## Personal\n\n* Back on track\n\n* Good health\n'
expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track\n* Good health\n')
expect(compactMarkdown(input)).toBe('## Personal\n\n- Back on track\n- Good health\n')
})
it('preserves single blank line between heading and content', () => {
@@ -54,17 +54,17 @@ describe('compactMarkdown', () => {
it('handles the exact bug scenario from the issue', () => {
const input = '## Personal\n\n* Back on track with Flavia\n\n* Good health vitals in place\n\n'
expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track with Flavia\n* Good health vitals in place\n')
expect(compactMarkdown(input)).toBe('## Personal\n\n- Back on track with Flavia\n- Good health vitals in place\n')
})
it('handles heading followed immediately by list (no blank line)', () => {
const input = '## Title\n* Item one\n\n* Item two\n'
expect(compactMarkdown(input)).toBe('## Title\n* Item one\n* Item two\n')
expect(compactMarkdown(input)).toBe('## Title\n- Item one\n- Item two\n')
})
it('handles mixed content: heading, paragraph, list', () => {
const input = '# Title\n\nSome intro.\n\n* Item one\n\n* Item two\n\nConclusion.\n'
expect(compactMarkdown(input)).toBe('# Title\n\nSome intro.\n\n* Item one\n* Item two\n\nConclusion.\n')
expect(compactMarkdown(input)).toBe('# Title\n\nSome intro.\n\n- Item one\n- Item two\n\nConclusion.\n')
})
it('preserves empty input', () => {
@@ -82,21 +82,41 @@ describe('compactMarkdown', () => {
it('handles list after code block', () => {
const input = '```\ncode\n```\n\n* Item one\n\n* Item two\n'
expect(compactMarkdown(input)).toBe('```\ncode\n```\n\n* Item one\n* Item two\n')
expect(compactMarkdown(input)).toBe('```\ncode\n```\n\n- Item one\n- Item two\n')
})
it('handles nested list items', () => {
const input = '* Parent\n\n * Child one\n\n * Child two\n'
expect(compactMarkdown(input)).toBe('* Parent\n * Child one\n * Child two\n')
expect(compactMarkdown(input)).toBe('- Parent\n - Child one\n - Child two\n')
})
it('handles checklist items', () => {
const input = '* [ ] Todo one\n\n* [ ] Todo two\n\n* [x] Done\n'
expect(compactMarkdown(input)).toBe('* [ ] Todo one\n* [ ] Todo two\n* [x] Done\n')
expect(compactMarkdown(input)).toBe('- [ ] Todo one\n- [ ] Todo two\n- [x] Done\n')
})
it('handles blockquotes normally', () => {
const input = '> Quote line one\n\n> Quote line two\n'
expect(compactMarkdown(input)).toBe('> Quote line one\n\n> Quote line two\n')
})
it('does not normalize * inside code blocks', () => {
const input = '```\n* not a list\n```\n'
expect(compactMarkdown(input)).toBe('```\n* not a list\n```\n')
})
it('decodes &#x20; HTML entities from BlockNote bold+code output', () => {
const input = '**Remove&#x20;**`NoteWindow`**&#x20;and render the full&#x20;**`App`**&#x20;component.**\n'
expect(compactMarkdown(input)).toBe('**Remove **`NoteWindow`** and render the full **`App`** component.**\n')
})
it('decodes multiple HTML entity types', () => {
const input = 'Use &#x26; for ampersand and &#x3C; for less-than.\n'
expect(compactMarkdown(input)).toBe('Use & for ampersand and < for less-than.\n')
})
it('does not decode HTML entities inside code blocks', () => {
const input = '```\n&#x20; should stay\n```\n'
expect(compactMarkdown(input)).toBe('```\n&#x20; should stay\n```\n')
})
})

View File

@@ -2,6 +2,8 @@
* Post-process BlockNote's blocksToMarkdownLossy output to produce
* standard-convention Markdown:
* - Tight lists (no blank lines between consecutive list items)
* - Bullet list markers normalized to `-` (BlockNote outputs `*`)
* - HTML entities like `&#x20;` decoded back to spaces
* - No runs of 3+ blank lines (collapsed to one blank line)
* - No trailing blank lines
* - Code block content is never modified
@@ -14,7 +16,7 @@ export function compactMarkdown(md: string): string {
let inCodeBlock = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
let line = lines[i]
// Track fenced code blocks — never modify content inside them
if (line.trimStart().startsWith('```')) {
@@ -28,6 +30,12 @@ export function compactMarkdown(md: string): string {
continue
}
// Normalize bullet markers: BlockNote uses `*`, convention is `-`
line = normalizeBulletMarker(line)
// Decode HTML entities that BlockNote inserts (e.g. &#x20; for spaces)
line = decodeHtmlEntities(line)
// Skip blank lines that sit between two list items (tight list rule)
if (line.trim() === '') {
if (isBlankBetweenListItems(lines, i)) continue
@@ -80,3 +88,15 @@ function findNextNonBlank(lines: string[], idx: number): number | null {
}
return null
}
const BULLET_RE = /^(\s*)\*(\s)/
/** Normalize `*` bullet markers to `-` (BlockNote default → standard convention) */
function normalizeBulletMarker(line: string): string {
return line.replace(BULLET_RE, '$1-$2')
}
/** Decode HTML entities that BlockNote inserts (&#x20; &#x26; etc.) */
function decodeHtmlEntities(line: string): string {
if (!line.includes('&#x')) return line
return line.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseFrontmatter } from './frontmatter'
import { parseFrontmatter, detectFrontmatterState } from './frontmatter'
describe('parseFrontmatter', () => {
describe('boolean-like Yes/No values', () => {
@@ -44,3 +44,37 @@ describe('parseFrontmatter', () => {
})
})
})
describe('detectFrontmatterState', () => {
it('returns "none" for null content', () => {
expect(detectFrontmatterState(null)).toBe('none')
})
it('returns "none" when no --- block exists', () => {
expect(detectFrontmatterState('Just a plain markdown file')).toBe('none')
})
it('returns "empty" for empty frontmatter block', () => {
expect(detectFrontmatterState('---\n---\nBody')).toBe('empty')
})
it('returns "empty" for whitespace-only frontmatter', () => {
expect(detectFrontmatterState('---\n \n---\nBody')).toBe('empty')
})
it('returns "valid" for well-formed frontmatter', () => {
expect(detectFrontmatterState('---\ntitle: Hello\ntype: Note\n---\nBody')).toBe('valid')
})
it('returns "valid" for frontmatter with only a title', () => {
expect(detectFrontmatterState('---\ntitle: Test\n---\n')).toBe('valid')
})
it('returns "invalid" for malformed YAML (missing colon)', () => {
expect(detectFrontmatterState('---\nthis is not yaml\n---\nBody')).toBe('invalid')
})
it('returns "invalid" for frontmatter with only garbage text', () => {
expect(detectFrontmatterState('---\n{broken: [yaml\n---\nBody')).toBe('invalid')
})
})

View File

@@ -29,6 +29,20 @@ function parseScalar(value: string): FrontmatterValue {
return clean
}
export type FrontmatterState = 'valid' | 'empty' | 'none' | 'invalid'
/** Detect whether content has valid, empty, missing, or invalid frontmatter. */
export function detectFrontmatterState(content: string | null): FrontmatterState {
if (!content) return 'none'
const match = content.match(/^---\n([\s\S]*?)---/)
if (!match) return 'none'
const body = match[1].trim()
if (!body) return 'empty'
// Valid frontmatter needs at least one line starting with a word character followed by colon
const hasValidLine = body.split('\n').some(line => /^[A-Za-z][\w -]*:/.test(line))
return hasValidLine ? 'valid' : 'invalid'
}
/** Parse YAML frontmatter from content */
export function parseFrontmatter(content: string | null): ParsedFrontmatter {
if (!content) return {}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, buildValidLinkTargets, isInboxEntry, filterInboxEntries } from './noteListHelpers'
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, buildValidLinkTargets, isInboxEntry, filterInboxEntries, filterEntries } from './noteListHelpers'
import type { VaultEntry } from '../types'
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
@@ -695,3 +695,47 @@ describe('filterInboxEntries', () => {
expect(result).toEqual([])
})
})
describe('filterEntries — folder selection', () => {
const entries = [
makeEntry({ path: '/vault/projects/laputa/note1.md', title: 'Note 1' }),
makeEntry({ path: '/vault/projects/laputa/note2.md', title: 'Note 2' }),
makeEntry({ path: '/vault/projects/portfolio/site.md', title: 'Site' }),
makeEntry({ path: '/vault/areas/health.md', title: 'Health' }),
makeEntry({ path: '/vault/root-note.md', title: 'Root' }),
]
it('filters entries by folder path', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' })
expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2'])
})
it('does not include notes from sibling folders', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' })
expect(result.find(e => e.title === 'Site')).toBeUndefined()
})
it('filters recursively — includes notes from subfolders', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'projects' })
expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2', 'Site'])
})
it('filters direct children', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'areas' })
expect(result.map(e => e.title)).toEqual(['Health'])
})
it('returns empty for root when entries are in subfolders', () => {
const result = filterEntries(entries, { kind: 'folder', path: 'nonexistent' })
expect(result).toEqual([])
})
it('excludes archived and trashed entries by default', () => {
const withArchived = [
...entries,
makeEntry({ path: '/vault/projects/laputa/archived.md', title: 'Archived', archived: true }),
]
const result = filterEntries(withArchived, { kind: 'folder', path: 'projects/laputa' })
expect(result.find(e => e.title === 'Archived')).toBeUndefined()
})
})

View File

@@ -313,8 +313,17 @@ function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): Vault
return entries.filter(isActive)
}
function isInFolder(entryPath: string, folderRelPath: string): boolean {
const needle = '/' + folderRelPath + '/'
return entryPath.includes(needle) || entryPath.startsWith(folderRelPath + '/')
}
function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter): VaultEntry[] {
if (selection.kind === 'entity') return []
if (selection.kind === 'folder') {
const folderEntries = entries.filter((e) => isInFolder(e.path, selection.path))
return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive)
}
if (selection.kind === 'sectionGroup') {
const typeEntries = entries.filter((e) => e.isA === selection.type)
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)

View File

@@ -19,56 +19,55 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
}
describe('attachClickHandlers', () => {
it('adds onItemClick to each candidate', () => {
const vaultPath = '/vault'
it('inserts relative path stem as wikilink target', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Note A', aliases: [], group: 'Note', entryTitle: 'Note A', path: '/a.md' },
{ title: 'Note B', aliases: [], group: 'Project', entryTitle: 'Note B', path: '/b.md' },
{ title: 'Note A', aliases: [], group: 'Note', entryTitle: 'Note A', path: '/vault/a.md' },
{ title: 'Note B', aliases: [], group: 'Project', entryTitle: 'Note B', path: '/vault/b.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
expect(result).toHaveLength(2)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('Note A')
expect(insertWikilink).toHaveBeenCalledWith('a|Note A')
result[1].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('Note B')
expect(insertWikilink).toHaveBeenCalledWith('b|Note B')
})
it('preserves all original properties', () => {
const result = attachClickHandlers(
[{ title: 'X', aliases: ['y'], group: 'Topic', entryTitle: 'X', path: '/x.md' }],
[{ title: 'X', aliases: ['y'], group: 'Topic', entryTitle: 'X', path: '/vault/x.md' }],
vi.fn(),
vaultPath,
)
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/x.md' })
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/vault/x.md' })
})
it('uses slug|title target when candidates have duplicate titles', () => {
it('includes subfolder path in wikilink target', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Status Update', aliases: [], group: 'Project', entryTitle: 'Status Update', path: '/vault/status-update.md' },
{ title: 'Status Update', aliases: [], group: 'Journal', entryTitle: 'Status Update', path: '/vault/status-update-2.md' },
{ title: 'ADR 001', aliases: [], group: 'Note', entryTitle: 'ADR 001', path: '/vault/docs/adr/0001-tauri-stack.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('status-update|Status Update')
result[1].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('status-update-2|Status Update')
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack|ADR 001')
})
it('uses title-only target when titles are unique', () => {
it('omits pipe display when title matches path stem', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Alpha', aliases: [], group: 'Note', entryTitle: 'Alpha', path: '/vault/alpha.md' },
{ title: 'Beta', aliases: [], group: 'Note', entryTitle: 'Beta', path: '/vault/beta.md' },
{ title: 'roadmap', aliases: [], group: 'Note', entryTitle: 'roadmap', path: '/vault/roadmap.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('Alpha')
expect(insertWikilink).toHaveBeenCalledWith('roadmap')
})
})

View File

@@ -5,6 +5,7 @@ import { deduplicateByPath, disambiguateTitles } from './wikilinkSuggestions'
import { bestSearchRank } from './fuzzyMatch'
import { filterSuggestionItems } from '@blocknote/core/extensions'
import type { WikilinkSuggestionItem } from '../components/WikilinkSuggestionMenu'
import { relativePathStem } from './wikilink'
const MAX_RESULTS = 20
@@ -16,31 +17,25 @@ interface BaseSuggestionItem {
path: string
}
/** Build a filename-based target with pipe display: "slug|Title" */
function buildPathTarget(item: BaseSuggestionItem): string {
const filename = item.path.split('/').pop() ?? ''
const slug = filename.replace(/\.md$/, '')
return `${slug}|${item.entryTitle}`
/** Build the wikilink target: relative path stem with pipe display for the title.
* e.g. "docs/adr/0001-tauri-stack|Tauri Stack" for subfolders,
* "roadmap|Roadmap" for root files. */
function buildTarget(item: BaseSuggestionItem, vaultPath: string): string {
const stem = relativePathStem(item.path, vaultPath)
return stem === item.entryTitle ? stem : `${stem}|${item.entryTitle}`
}
/** Add onItemClick to raw suggestion candidates.
* When multiple candidates share the same title, inserts a path-based
* target with pipe syntax so the wikilink uniquely identifies the note. */
* Always inserts the vault-relative path as the wikilink target
* so links are unambiguous and work across subfolders. */
export function attachClickHandlers(
candidates: BaseSuggestionItem[],
insertWikilink: (target: string) => void,
vaultPath: string,
) {
const titleCounts = new Map<string, number>()
for (const item of candidates) {
titleCounts.set(item.entryTitle, (titleCounts.get(item.entryTitle) ?? 0) + 1)
}
return candidates.map(item => ({
...item,
onItemClick: () => {
const isDuplicate = (titleCounts.get(item.entryTitle) ?? 0) > 1
insertWikilink(isDuplicate ? buildPathTarget(item) : item.entryTitle)
},
onItemClick: () => insertWikilink(buildTarget(item, vaultPath)),
}))
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import type { VaultEntry } from '../types'
import { wikilinkTarget, wikilinkDisplay, resolveEntry } from './wikilink'
import { wikilinkTarget, wikilinkDisplay, resolveEntry, relativePathStem } from './wikilink'
function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
return {
@@ -96,4 +96,31 @@ describe('resolveEntry', () => {
// Searching for "bar" should match barEntry (by filename stem) not fooEntry (by title)
expect(resolveEntry(ambiguous, 'bar')).toBe(barEntry)
})
it('resolves path-style target by matching path suffix', () => {
const adr = makeEntry({ path: '/vault/docs/adr/0031-foo.md', filename: '0031-foo.md', title: '0031 Foo' })
const flat = makeEntry({ path: '/vault/hello.md', filename: 'hello.md', title: 'Hello' })
expect(resolveEntry([adr, flat], 'docs/adr/0031-foo')).toBe(adr)
})
it('disambiguates same-name files in different subfolders via path', () => {
const alpha = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha' })
const alphaArchived = makeEntry({ path: '/vault/archive/alpha.md', filename: 'alpha.md', title: 'Alpha' })
expect(resolveEntry([alpha, alphaArchived], 'projects/alpha')).toBe(alpha)
expect(resolveEntry([alpha, alphaArchived], 'archive/alpha')).toBe(alphaArchived)
})
})
describe('relativePathStem', () => {
it('extracts relative path stem from absolute path and vault path', () => {
expect(relativePathStem('/Users/luca/Vault/note.md', '/Users/luca/Vault')).toBe('note')
})
it('preserves subdirectory structure', () => {
expect(relativePathStem('/Users/luca/Vault/docs/adr/0031.md', '/Users/luca/Vault')).toBe('docs/adr/0031')
})
it('falls back to filename stem when vault path does not match', () => {
expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note')
})
})

View File

@@ -18,39 +18,53 @@ export function wikilinkDisplay(ref: string): string {
return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
/** Extract the vault-relative path stem (no leading slash, no .md extension). */
export function relativePathStem(absolutePath: string, vaultPath: string): string {
const prefix = vaultPath.endsWith('/') ? vaultPath : vaultPath + '/'
if (absolutePath.startsWith(prefix)) return absolutePath.slice(prefix.length).replace(/\.md$/, '')
// Fallback: just the filename stem
const filename = absolutePath.split('/').pop() ?? absolutePath
return filename.replace(/\.md$/, '')
}
/**
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
* Handles pipe syntax, case-insensitive matching.
* Resolution order (multi-pass, global priority):
* 1. Filename stem match (strongest — filename IS the identity in flat vault)
* 2. Alias match
* 3. Exact title match
* 4. Humanized title match (kebab-case → words)
* No path-based matching — flat vault uses title/filename only.
* Legacy path-style targets like "person/alice" are handled by extracting the last segment.
* 1. Path-suffix match (for path-style targets like "docs/adr/0031-foo")
* 2. Filename stem match (strongest for flat vaults)
* 3. Alias match
* 4. Exact title match
* 5. Humanized title match (kebab-case → words)
*/
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const keyLower = key.toLowerCase()
// For legacy path-style targets like "person/alice", extract just the last segment
const lastSegment = key.includes('/') ? (key.split('/').pop() ?? key) : key
const lastSegmentLower = lastSegment.toLowerCase()
const asWords = lastSegmentLower.replace(/-/g, ' ')
const pathSuffix = key.includes('/') ? '/' + key.toLowerCase() + '.md' : null
// Pass 1: filename stem (strongest match — filename IS identity in flat vault)
// Pass 1: path-suffix match (for subfolder targets like "docs/adr/0031-foo")
if (pathSuffix) {
for (const e of entries) {
if (e.path.toLowerCase().endsWith(pathSuffix)) return e
}
}
// Pass 2: filename stem (strongest for flat vault)
for (const e of entries) {
const stem = e.filename.replace(/\.md$/, '').toLowerCase()
if (stem === keyLower || stem === lastSegmentLower) return e
}
// Pass 2: alias
// Pass 3: alias
for (const e of entries) {
if (e.aliases.some(a => a.toLowerCase() === keyLower)) return e
}
// Pass 3: exact title
// Pass 4: exact title
for (const e of entries) {
if (e.title.toLowerCase() === keyLower || e.title.toLowerCase() === lastSegmentLower) return e
}
// Pass 4: humanized title (kebab-case → words)
// Pass 5: humanized title (kebab-case → words)
if (asWords !== keyLower) {
for (const e of entries) {
if (e.title.toLowerCase() === asWords) return e

View File

@@ -25,7 +25,8 @@ export function preFilterWikilinks<T extends WikilinkBaseItem>(
return items.filter(item =>
item.title.toLowerCase().includes(lowerQuery) ||
item.aliases.some(a => a.toLowerCase().includes(lowerQuery)) ||
item.group.toLowerCase().includes(lowerQuery)
item.group.toLowerCase().includes(lowerQuery) ||
item.path.toLowerCase().includes(lowerQuery)
)
}

View File

@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test'
/** Errors that indicate the app has crashed (not just minor internal warnings). */
function isCrashError(msg: string): boolean {
return msg.includes('Maximum update depth') || msg.includes('Invalid hook call') || msg.includes('#185')
}
test('create note via Cmd+N does not crash', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (isCrashError(err.message)) errors.push(err.message) })
await page.goto(process.env.BASE_URL ?? 'http://localhost:5201')
await page.waitForSelector('[data-testid="sidebar-top-nav"]', { timeout: 10000 })
await page.waitForTimeout(500)
await page.keyboard.press('Meta+n')
await page.waitForTimeout(2000)
expect(errors).toHaveLength(0)
await expect(page.locator('[data-testid="title-field-input"]')).toBeVisible()
})
test('create note via sidebar + button does not crash', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (isCrashError(err.message)) errors.push(err.message) })
await page.goto(process.env.BASE_URL ?? 'http://localhost:5201')
await page.waitForSelector('[data-testid="sidebar-top-nav"]', { timeout: 10000 })
await page.waitForTimeout(500)
const plusButtons = page.locator('button[aria-label*="Create new"]')
if (await plusButtons.count() > 0) {
await plusButtons.first().click()
await page.waitForTimeout(2000)
}
expect(errors).toHaveLength(0)
await expect(page.locator('[data-testid="title-field-input"]')).toBeVisible()
})

View File

@@ -1,48 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Flat vault structure', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('new note is created at vault root (no type folder in path)', async ({ page }) => {
// Create a new note via Ctrl+N (mock Cmd+N)
await page.locator('body').click()
await page.keyboard.press('Control+n')
// Wait for the editor to appear (note was created)
await page.waitForTimeout(500)
// Check that no toast says "Note moved" — type change should not move file
const movedToast = page.locator('text=Note moved')
await expect(movedToast).not.toBeVisible()
})
test('changing type via frontmatter does NOT show move toast', async ({ page }) => {
// Create a note first
await page.locator('body').click()
await page.keyboard.press('Control+n')
await page.waitForTimeout(500)
// Verify no "Note moved" toast appears (since move_note_to_type_folder is removed)
const movedToast = page.locator('text=Note moved')
await expect(movedToast).not.toBeVisible()
})
test('app loads without errors', async ({ page }) => {
// Verify the app loaded — check that the main container exists
const main = page.locator('#root')
await expect(main).toBeVisible()
// No console errors about move_note_to_type_folder
const errors: string[] = []
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text())
})
await page.waitForTimeout(1000)
const moveErrors = errors.filter(e => e.includes('move_note_to_type_folder'))
expect(moveErrors).toHaveLength(0)
})
})

View File

@@ -0,0 +1,93 @@
import { test, expect } from '@playwright/test'
test.describe('Raw editor type propagation', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('editing type in raw editor immediately updates Properties panel', async ({ page }) => {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.waitFor({ timeout: 5000 })
const items = noteList.locator('.cursor-pointer')
const count = await items.count()
test.skip(count === 0, 'No notes in note list')
// Click first note and open Properties panel
await items.nth(0).click()
await page.waitForTimeout(300)
await page.keyboard.press('Control+Shift+i')
await page.waitForTimeout(500)
// Find a note with a visible type selector (skip Theme)
const typeSelector = page.locator('[data-testid="type-selector"]')
let originalType = ''
for (let i = 0; i < Math.min(count, 10); i++) {
await items.nth(i).click()
await page.waitForTimeout(400)
if (!(await typeSelector.isVisible())) continue
const trigger = typeSelector.locator('button[role="combobox"]')
const text = (await trigger.textContent())?.trim() ?? ''
if (text && !text.includes('Theme')) {
originalType = text
break
}
}
test.skip(!originalType, 'No non-Theme note with type selector found')
// Open raw editor (Ctrl+\)
await page.keyboard.press('Control+Backslash')
const rawEditor = page.locator('[data-testid="raw-editor-codemirror"]')
await expect(rawEditor).toBeVisible({ timeout: 3000 })
await page.waitForTimeout(300)
// Find the "type:" line in the editor
const typeLineIndex = await page.evaluate(() => {
const lines = document.querySelectorAll('.cm-line')
for (let i = 0; i < lines.length; i++) {
if (lines[i].textContent?.match(/^(?:type|Is A):\s/i)) return i
}
return -1
})
test.skip(typeLineIndex < 0, 'No type field found in frontmatter')
// Click the type line, select it, and retype with new type
const typeLine = page.locator('.cm-line').nth(typeLineIndex)
await typeLine.click()
await page.waitForTimeout(100)
await page.keyboard.press('Home')
await page.keyboard.press('Shift+End')
const newType = originalType.includes('Note') ? 'Project' : 'Note'
await page.keyboard.type(`type: ${newType}`)
// Wait for debounce (500ms) + state propagation
await page.waitForTimeout(800)
// Verify Properties panel shows the new type
const trigger = typeSelector.locator('button[role="combobox"]')
await expect(trigger).toContainText(newType, { timeout: 3000 })
// Restore: select the type line and retype original
const restoreLineIndex = await page.evaluate(() => {
const lines = document.querySelectorAll('.cm-line')
for (let i = 0; i < lines.length; i++) {
if (lines[i].textContent?.match(/^type:\s/)) return i
}
return -1
})
if (restoreLineIndex >= 0) {
const restoreLine = page.locator('.cm-line').nth(restoreLineIndex)
await restoreLine.click()
await page.keyboard.press('Home')
await page.keyboard.press('Shift+End')
await page.keyboard.type(`type: ${originalType.replace(/Note$/, '')}`)
await page.waitForTimeout(800)
}
// Close raw editor
await page.keyboard.press('Control+Backslash')
await page.waitForTimeout(300)
})
})

View File

@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test'
test('clicking + in type section creates note with that type', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(2000)
// Click on "Projects" in the sidebar to select that type section
const projectsItem = page.locator('[data-testid="sidebar-section-Projects"]').or(page.locator('.app__sidebar').locator('text=Projects').first())
await projectsItem.click()
await page.waitForTimeout(1000)
// Click the "+" button to create a new note
await page.click('[title="Create new note"]')
await page.waitForTimeout(1000)
// The new note should have type-based naming (e.g., "Untitled project N")
const titleInput = page.locator('[data-testid="title-field-input"]')
if (await titleInput.count() > 0) {
const value = await titleInput.inputValue()
console.log('New note title:', value)
expect(value.toLowerCase()).toContain('project')
}
// Toggle raw editor to see frontmatter
await page.keyboard.press('Meta+Shift+m')
await page.waitForTimeout(500)
const rawEditor = page.locator('.cm-content')
if (await rawEditor.count() > 0) {
const content = await rawEditor.textContent()
console.log('Raw content:', content?.substring(0, 200))
expect(content).toContain('type: Project')
}
})
test('clicking + in All Notes creates generic note', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(2000)
// Click on "All Notes" in sidebar
await page.locator('text=All Notes').first().click()
await page.waitForTimeout(500)
// Click the "+" button
await page.click('[title="Create new note"]')
await page.waitForTimeout(1000)
// The new note should be a generic "Note" type
const titleInput = page.locator('[data-testid="title-field-input"]')
if (await titleInput.count() > 0) {
const value = await titleInput.inputValue()
console.log('New note title:', value)
expect(value.toLowerCase()).toContain('note')
expect(value.toLowerCase()).not.toContain('project')
}
})

File diff suppressed because it is too large Load Diff