Compare commits

...

29 Commits

Author SHA1 Message Date
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
Test
c29206da3b docs: add ADR-0031, ADR-0032; update README index (guard — from commits 98a98ab, 6d405a7) 2026-03-31 08:01:19 +02:00
71 changed files with 1664 additions and 220 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.84
AVERAGE_THRESHOLD=9.38

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

@@ -0,0 +1,30 @@
---
type: ADR
id: "0031"
title: "Full App instance for secondary note windows"
status: active
date: 2026-03-31
---
## Context
Laputa supports opening a note in a secondary window ("Open in New Window"). The original implementation used a dedicated `NoteWindow` component — a thin shell that rendered only the editor, duplicating some App-level logic (vault loading, settings, keyboard shortcuts) in a simplified but diverging form. As the main App gained features (properties editing, zoom, command palette, keyboard shortcuts), the `NoteWindow` shell fell behind, requiring ongoing maintenance to keep parity.
## Decision
**Remove `NoteWindow` and render the full `App` component in secondary note windows.** The window type is detected at startup via URL query parameters (`?window=note&path=...&vault=...`). When in note-window mode, the App initialises with panels hidden (sidebar collapsed, inspector collapsed) and auto-opens the target note once vault entries load. The window title is kept in sync with the active note title via the Tauri window API.
## Options considered
- **Keep `NoteWindow` shell** (status quo): lower initial bundle weight per window, but divergence grows with every main-App feature. Rejected — maintenance cost dominates.
- **Full `App` instance with URL-param mode** (chosen): complete feature parity for free; single code path for all window types. Trade-off: slightly heavier startup for secondary windows (full vault load), acceptable given local filesystem speed.
- **IPC-driven secondary window (no vault reload)**: secondary window subscribes to primary window's vault state via Tauri events. Maximum efficiency, avoids double vault reads. Deferred — requires significant IPC plumbing; can be layered on top later without changing the rendering model.
## Consequences
- Removes ~163 lines (`NoteWindow.tsx` deleted entirely)
- Secondary note windows get full feature parity: all keyboard shortcuts, properties panel, zoom, command palette, diff mode, raw editor
- `useLayoutPanels` gains an `initialInspectorCollapsed` option to support the hidden-panel initial state
- A new `src/utils/windowMode.ts` utility encapsulates URL-param detection — single source of truth for window-type logic
- Vault is loaded independently in each note window (no shared state with the main window); writes go to the same filesystem so eventual consistency is maintained via file-watching
- Triggers re-evaluation if: multiple simultaneous note windows cause measurable vault-read contention, or if IPC-driven shared-state windows become a product requirement

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0032"
title: "Git actions (Changes, Pulse, Commit) in status bar, not sidebar"
status: active
date: 2026-03-31
---
## Context
The Laputa sidebar originally surfaced git-related affordances — a "Changes" nav item (visible when modified files > 0), a "Pulse" nav item, and a "Commit & Push" button — alongside the note-type navigation filters and sections. This mixed two concerns in the sidebar: **navigation** (where to go) and **git status / actions** (what changed, what to do). As the sidebar grew, the git items created visual noise and made the nav hierarchy harder to scan.
## Decision
**Move Changes, Pulse, and Commit & Push out of the sidebar and into the bottom status bar.** The status bar shows a GitDiff icon with an orange count badge for modified files; a Pulse icon sits next to it. Commit & Push is accessible via an icon button beside the Changes indicator. The sidebar now contains only navigation items (filters and type sections).
## 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.
## 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

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0033"
title: "Subfolder scanning and folder tree navigation"
status: active
date: 2026-03-31
---
## Context
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 `.md` files in all visible subdirectories, and expose the vault's folder tree via a new `list_vault_folders` 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

@@ -86,3 +86,6 @@ proposed → active → superseded
| [0028](0028-cli-agent-only-no-api-key.md) | CLI agent only — no direct Anthropic API key | active |
| [0029](0029-domain-command-builder-pattern.md) | Domain command builder pattern for useCommandRegistry | active |
| [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 |

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);

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,

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

@@ -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 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};
@@ -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]
@@ -1211,6 +1218,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

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

@@ -56,6 +56,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,6 +97,24 @@ 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()
@@ -113,6 +133,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 +257,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])
@@ -386,6 +439,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 +477,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} inboxCount={inboxCount} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -445,6 +515,7 @@ function App() {
onDeleteProperty={notes.handleDeleteProperty}
onAddProperty={notes.handleAddProperty}
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
onInitializeProperties={handleInitializeProperties}
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
@@ -488,6 +559,7 @@ function App() {
/>
)}
<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;

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

@@ -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,117 @@
import { useState, useCallback, 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
}
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 }: FolderTreeProps) {
const [sectionCollapsed, setSectionCollapsed] = useState(false)
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const toggleFolder = useCallback((path: string) => {
setExpanded((prev) => ({ ...prev, [path]: !prev[path] }))
}, [])
if (folders.length === 0) 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>
<Plus size={12} className="text-muted-foreground" />
</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}
/>
))}
</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

@@ -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(
@@ -102,9 +103,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
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">
<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 +112,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,7 @@ interface SidebarProps {
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
onRenameSection?: (typeName: string, label: string) => void
onToggleTypeVisibility?: (typeName: string) => void
folders?: FolderNode[]
inboxCount?: number
onCollapse?: () => void
}
@@ -205,7 +207,7 @@ export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
onToggleTypeVisibility,
inboxCount = 0, onCollapse,
folders = [], inboxCount = 0, onCollapse,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -288,10 +290,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 +315,9 @@ export const Sidebar = memo(function Sidebar({
))}
</SortableContext>
</DndContext>
{/* Folder tree */}
<FolderTree folders={folders} selection={selection} onSelect={onSelect} />
</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 && activeBadgeStyle ? 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

@@ -437,7 +437,7 @@ 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 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0, overflow: 'hidden' }}>
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} onRemoveVault={onRemoveVault} />
<span style={SEP_STYLE}>|</span>
<span
@@ -449,15 +449,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>
<ChangesBadge count={modifiedCount} onClick={onClickPending} onCommitPush={onCommitPush} />
<span style={SEP_STYLE}>|</span>
<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

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

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

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

@@ -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 () => {
@@ -168,7 +172,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

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

@@ -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,42 @@ 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 by parent folder (non-recursive — direct children only)', () => {
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,21 @@ function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): Vault
return entries.filter(isActive)
}
function isInFolder(entryPath: string, folderRelPath: string): boolean {
const sep = '/'
const suffix = sep + folderRelPath + sep
const dirEnd = entryPath.lastIndexOf(sep)
if (dirEnd < 0) return false
const entryDir = entryPath.slice(0, dirEnd + 1)
return entryDir.endsWith(suffix)
}
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,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)
})
})