Compare commits

...

40 Commits

Author SHA1 Message Date
Test
8207ee4569 test: fix E2E test — search for 'reload' instead of removed 'reindex'
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:31:51 +01:00
Test
2fdc122d73 style: rustfmt search.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:21:29 +01:00
Test
60d3b48ea6 fix: resolve clippy single-element-loop in build.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:20:23 +01:00
Test
ecbb94ae83 refactor: remove QMD semantic indexing — keep keyword search only
Remove the entire QMD search engine (semantic indexing, hybrid search,
vector embeddings) and replace with a simple walkdir-based keyword
search. QMD never worked reliably in production, added bundle bloat,
and showed "Index failed" in the status bar.

What changed:
- Rust: deleted indexing.rs, rewrote search.rs as pure keyword search
- Frontend: removed useIndexing hook, IndexingBadge, hybrid search
- Menu: removed "Reindex Vault" command from palette and menu bar
- Config: removed qmd from bundle resources and build script
- Deleted tools/qmd/ (QMD CLI tool, MCP server, tests)
- Updated docs (ARCHITECTURE, ABSTRACTIONS, GETTING-STARTED)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:18:05 +01:00
Test
50ad7e0e8c docs: add Boy Scout Rule to CLAUDE.md — leave every touched file better than found 2026-03-24 15:58:23 +01:00
Test
ea8d847d46 ci: add Average Code Health gate (≥8.9 floor, target 9.5) to CI, pre-commit, pre-push
- Both hotspot (≥9.5) and average (≥8.9) gates now block commit/push/CI
- Current average: 8.97 — threshold set at 8.9 to block regressions without
  blocking the current state; aspirational target remains 9.5
- pre-commit: replaced phantom pre_commit_code_health_safeguard with real inline check
- pre-push: CodeScene step is now blocking (was informational-only)
- CLAUDE.md: explicit instructions to monitor both scores via MCP CodeScene
2026-03-24 15:47:31 +01:00
Test
845181d002 refactor: move vault UI config from ui.config.md to localStorage
Replace the in-vault ui.config.md file with localStorage persistence
keyed by vault path. Vaults now contain only user content — no app
config files. Removes the Rust vault_config module, Tauri commands
(get/save_vault_config), startup migrations, and demo vault config files.

The reactive store (vaultConfigStore) and consumer hooks (useZoom,
useViewMode, useRawMode) are unchanged — only the storage backend moved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:07:38 +01:00
Test
35c62583d9 style: rustfmt use statement
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:01:15 +01:00
Test
a6b2454184 refactor: remove theming system entirely
Remove all theme switching, creation, and management functionality
from the app — backend (Rust theme module, Tauri commands, menu items,
startup tasks, vault seeding), frontend (useThemeManager, ThemePropertyEditor,
themeSchema, command palette Appearance group, settings panel appearance
section, isDarkTheme prop chain), mock data, and all related tests.

Editor styling via theme.json/EditorTheme.css is preserved (not user theming).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 19:57:58 +01:00
Test
a74f76fdf1 test: add sidebar emoji icon rendering tests
Verify that SectionChildItem renders emoji icons before note titles
in expanded sidebar sections, and that notes without icons render
without any extra content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:32:16 +01:00
Test
c6fa1f48cb feat: add filter pills (Open/Archived/Trashed) to All Notes view
Reuses the existing FilterPills component and sub-filter mechanism from
sectionGroup views. Adds countAllByFilter helper for counting across all
entry types and extends filterByKind to support subFilter for the 'all'
filter. Bulk actions automatically adapt based on active pill. Switching
pills resets multi-selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 23:03:17 +01:00
Test
8f8954a6f7 fix: inbox sidebar ordering, filter chip wrapping, and editor banner architecture
- Move Inbox to first position in sidebar (before All Notes)
- Add whitespace-nowrap to filter pills + flex-wrap on container so chips
  wrap as whole units instead of breaking text internally
- Editor now reads trashed/archived state from fresh vault entries instead
  of potentially stale tab entry, ensuring banners appear regardless of
  navigation context
- Update tests to pass correct entries prop alongside tabs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:36:59 +01:00
Test
99f5716508 feat: show emoji icon in sidebar note items
Pass the icon field from VaultEntry to SectionChildItem and render
it before the title when present, matching the pattern used in
NoteItem and other contexts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:58:34 +01:00
Test
b8f29c9530 fix: align type selector chip left using grid layout matching property rows
The type selector was using flex justify-between, pushing the chip to the
right side of the Properties panel. Switch to grid-cols-2 layout (matching
PropertyRow and InfoRow) so the chip sits left-aligned in the value column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:27:51 +01:00
Test
33ae00a558 style: apply rustfmt to vault_config.rs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:04:05 +01:00
Test
ac02de88e6 fix: store ui.config.md at vault root instead of config/ directory
Repair Vault was creating config/ui.config.md inside a config/ subdirectory.
All vault config files should live at vault root (flat structure). Changed
config_path() to point to vault root, added migrate_ui_config_to_root() for
legacy migration, and wired it into both startup and repair_vault flows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 21:00:19 +01:00
Test
fb8208cfa0 fix: use CSS grid for 50/50 label/value layout in properties panel
Flex layout with gap-2 allowed content to override the 50% width split,
causing short labels like "URL" to be truncated to "U…" when values were
long. Switching to grid-cols-2 enforces exact 50/50 columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:27:36 +01:00
Test
66e29b70b8 fix: match TitleField font-size/weight to BlockNote H1 and fix left alignment
TitleField now uses var(--headings-h1-font-size/weight/line-height/letter-spacing)
instead of hardcoded 28px, matching the editor H1 exactly. Added margin-left: 8px
to align with BlockNote's bn-block-content offset. Fixed bn-editor max-width to
use the same CSS var as the title-section for consistent horizontal alignment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 20:03:26 +01:00
Test
d1b358f76a fix: remove incorrect wikilink assertion from snippet smoke test
Wikilinks ([[note name]]) are valid content in note snippets — they are
plain-text references, not raw markdown formatting. The test was failing
against demo-vault data containing wikilinks in renamed-title-xyz.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:38:51 +01:00
Test
c2ce67c300 test: add smoke test for focus-event UI freeze regression
Verifies that window focus events don't block the main thread for >500ms,
covering both single focus and rapid 5x focus (Cmd+Tab spam) scenarios.
Completes the regression test requirement for the git-commands-off-main-thread fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:28:53 +01:00
Test
07522e984c fix: eliminate UI freeze on app focus by moving git commands off main thread
Sync Tauri commands (git_pull, git_push, git_remote_status, reload_vault)
blocked the runtime thread during network I/O, freezing the UI for 2-3s
on every Cmd+Tab. Converted them to async with tokio::spawn_blocking.
Added 30s cooldown to focus-triggered git pull and theme settings reload
to prevent redundant work on rapid app switching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:05:23 +01:00
Test
36f43c1ae0 fix: note list resolves relationships by title/alias matching unified resolveEntry
resolveRefs() and refsMatch() in noteListHelpers used a simple 2-pass
matching (path stem + filename stem), while the Inspector used the unified
resolveEntry() with 4-pass resolution (filename, alias, title, humanized
title). Notes matched only by title or alias were silently dropped from the
note list, causing incomplete relationship groups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 16:03:50 +01:00
Test
1b478d0fc1 fix: remove vertical padding from PropertyRow to match InfoRow density
PropertyRow had py-0.5 (4px total) while InfoRow had no vertical padding,
making Properties rows taller than Info rows even with equal gap values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:26:25 +01:00
Test
8646be6b8d fix: force WKWebView pseudo-element style recalc on theme CSS var changes
WKWebView doesn't invalidate ::before/::after pseudo-element styles when
CSS custom properties change via inline styles alone — offsetHeight reflow
only triggers layout, not style recalculation. This caused bullet size and
bullet color (rendered via ::before on bulletListItem) to not update live
when editing a theme note and saving with Cmd+S.

Fix: after setting CSS vars as inline styles, also inject them into a
<style> element. Replacing <style> content forces a full style tree
invalidation in WebKit, covering pseudo-elements that reference var().

Also extract shouldDeactivate/deactivateTheme helpers from useThemeManager
to reduce cyclomatic complexity (CodeScene: 8.77 → 9.6).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 08:41:30 +01:00
Test
fd9b4fe5e7 refactor: NoteList.test.tsx -- deduplicate makeEntry helpers and bulk action tests (CodeScene: 7.78 to 10.0)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 07:18:34 +01:00
Test
d52365882c style: rustfmt formatting for mod_tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 03:28:36 +01:00
Test
135fe62d21 fix: note list shows incomplete relationships when opening from sidebar
Two root causes:
1. noteListHooks used a stale selection.entry to build relationship groups —
   now looks up the fresh entry from the entries array so relationship updates
   propagate immediately.
2. frontmatterToEntryPatch didn't update entry.relationships — added
   RelationshipPatch support so frontmatter changes also update the
   relationships map in state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 03:26:52 +01:00
Test
99aee0f67b fix: update smoke tests to find Changes badge in secondary sidebar area
The Changes NavItem moved from the top nav to the sidebar-secondary area,
so Playwright locators need to scope within the new data-testid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:41:04 +01:00
Test
a369b3d93e feat: move Changes and Pulse to secondary bottom area in sidebar
Changes and Pulse are git status UI, not content navigation. Moving them
out of the main top nav into a compact secondary area at the bottom of
the sidebar keeps the primary section focused on vault content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:29:39 +01:00
Test
4e88cf71b1 style: rustfmt import formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:53:42 +01:00
Test
35bbe221b8 fix: remove invalid weight prop from lucide AlertTriangle icon
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:52:02 +01:00
Test
05dca72ef3 feat: handle git divergence, conflicts, and manual pull from within Laputa
When push is rejected due to remote having newer commits, the bottom bar
now shows "Pull required" (orange). Clicking it pulls then auto-pushes.
Conflicted notes show an inline banner with "Keep mine" / "Keep theirs"
buttons. A new "Pull from Remote" command is available in Cmd+K and the
Vault menu. Clicking the sync badge opens a popup showing branch name,
ahead/behind counts, and a Pull button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 09:51:06 +01:00
Test
57a66e4788 test: add Playwright smoke test for Open in New Window command
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 08:56:58 +01:00
Test
7b0b31455b fix: use lowercase titleBarStyle for Tauri v2 WebviewWindow
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 08:48:20 +01:00
Test
7c16ebd065 feat: open note in new window (Cmd+Shift+Click / Cmd+Shift+O)
Add multi-window support: notes can be opened in dedicated secondary
Tauri windows with editor-only layout (no sidebar, no note list).

Triggers: Cmd+Shift+Click on notes, Cmd+K → "Open in New Window",
Cmd+Shift+O shortcut, Note → "Open in New Window" menu bar item.

Secondary windows have their own auto-save, theme, and wikilink
navigation. Closing a secondary window does not affect the main window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 08:47:25 +01:00
Test
5df4a7a3ad fix: reduce Properties row gap to match Info section density
Changed property rows gap from gap-2 (8px) to gap-1.5 (6px) to match
the Info section's vertical density.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:56:26 +01:00
Test
ca41008850 feat: adopt relationship chip style for type selector in Properties panel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 07:29:07 +01:00
Test
badbf141dd fix: enforce 50/50 label/value layout in Properties panel with ellipsis
Labels and values each get w-1/2 so neither can squeeze the other.
Long values no longer cause short labels like "URL" to truncate to "U…".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 06:57:11 +01:00
Test
bc55231baa feat: add Inbox sidebar section showing unlinked notes
Inbox shows notes without valid outgoing relationships (body wikilinks
or frontmatter refs), helping users find captured but unorganized notes.
Includes time-period filter pills (This week/month/quarter/All time),
Cmd+K command, and macOS menu bar entry. Broken wikilinks (targeting
non-existent notes) are not counted as relationships.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 06:41:40 +01:00
Test
24da33e7cd feat: auto-save notes with 500ms debounce after last keystroke
Content is automatically persisted 500ms after the last edit in both
BlockNote and raw editor modes. Cmd+S still works as immediate flush.
Tab close flushes any pending auto-save to prevent data loss.
Updated the "unsaved" tab indicator to show "Auto-saving…" with pulse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 05:34:48 +01:00
128 changed files with 3000 additions and 22331 deletions

View File

@@ -80,31 +80,44 @@ jobs:
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
# ── 3. Code Health (CodeScene — Hotspot Code Health gate) ────────────
# The webhook integration handles per-PR delta analysis (posts review
# comments on PRs). This step enforces a minimum floor on the
# project-wide Hotspot Code Health score (weighted avg of the most
# frequently edited files — the ones that matter most).
# Current baseline: 9.53 | Aspirational target: 9.8
- name: Hotspot Code Health gate (≥9.5)
# ── 3. Code Health (CodeScene — Hotspot + Average Code Health gates) ──
# Enforces minimum floors on BOTH hotspot and average code health.
# Hotspot: weighted avg of most-edited files (9.6 current | target 9.8)
# Average: project-wide avg across all files (8.9 current | target 9.5)
# Both gates must pass — average catches regressions in non-hotspot files.
- name: Code Health gates (Hotspot ≥9.5 + Average ≥9.0)
env:
CODESCENE_PAT: ${{ secrets.CODESCENE_PAT }}
CODESCENE_PROJECT_ID: ${{ secrets.CODESCENE_PROJECT_ID }}
run: |
THRESHOLD=9.5
SCORE=$(curl -sf \
HOTSPOT_THRESHOLD=9.5
AVERAGE_THRESHOLD=8.9
API_RESPONSE=$(curl -sf \
-H "Authorization: Bearer $CODESCENE_PAT" \
-H "Accept: application/json" \
"https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])")
echo "Hotspot Code Health: $SCORE (threshold: $THRESHOLD)"
"https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID")
HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])")
AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['code_health']['now'])")
echo "Hotspot Code Health: $HOTSPOT_SCORE (threshold: $HOTSPOT_THRESHOLD)"
echo "Average Code Health: $AVERAGE_SCORE (threshold: $AVERAGE_THRESHOLD)"
python3 -c "
score = float('$SCORE')
threshold = float('$THRESHOLD')
if score < threshold:
print(f'❌ Hotspot Code Health {score:.2f} is below threshold {threshold}')
hotspot = float('$HOTSPOT_SCORE')
average = float('$AVERAGE_SCORE')
ht = float('$HOTSPOT_THRESHOLD')
at = float('$AVERAGE_THRESHOLD')
failed = False
if hotspot < ht:
print(f'❌ Hotspot Code Health {hotspot:.2f} is below threshold {ht}')
failed = True
else:
print(f'✅ Hotspot Code Health {hotspot:.2f} ≥ {ht}')
if average < at:
print(f'❌ Average Code Health {average:.2f} is below threshold {at}')
failed = True
else:
print(f'✅ Average Code Health {average:.2f} ≥ {at}')
if failed:
exit(1)
print(f'✅ Hotspot Code Health {score:.2f} ≥ {threshold}')
"
# ── 4. Documentation check (warning only — does not fail build) ───────

View File

@@ -16,3 +16,46 @@ echo " → tests..."
pnpm test --run --silent
echo "✅ Pre-commit passed"
# ── CodeScene Code Health gate ────────────────────────────────────────────
# Blocks commit if Hotspot < 9.5 OR Average < 9.0.
# Note: remote scores lag behind local changes (update after push + re-analysis).
# This catches regressions from previous pushes and notifies Claude Code immediately.
# If `pre_commit_code_health_safeguard` fails: extract hooks, split components,
# reduce complexity. Never use eslint-disable, #[allow(...)], or `as any`.
echo "🏥 CodeScene code health check..."
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping (CI will enforce)"
else
API_RESPONSE=$(curl -sf \
-H "Authorization: Bearer $CODESCENE_PAT" \
-H "Accept: application/json" \
"https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID" 2>/dev/null || echo "{}")
HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])" 2>/dev/null || echo "")
AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['code_health']['now'])" 2>/dev/null || echo "")
if [ -z "$HOTSPOT_SCORE" ] || [ -z "$AVERAGE_SCORE" ]; then
echo " ⚠️ Could not fetch CodeScene scores — skipping (CI will enforce)"
else
echo " Hotspot Code Health: $HOTSPOT_SCORE (threshold: 9.5)"
echo " Average Code Health: $AVERAGE_SCORE (threshold: 8.9)"
python3 -c "
import sys
hotspot = float('$HOTSPOT_SCORE')
average = float('$AVERAGE_SCORE')
failed = False
if hotspot < 9.5:
print(f'FAIL: Hotspot Code Health {hotspot:.2f} < 9.5 — extract hooks, split components, reduce complexity')
failed = True
else:
print(f'OK: Hotspot {hotspot:.2f} >= 9.5')
if average < 8.9:
print(f'FAIL: Average Code Health {average:.2f} < 9.0 — recent changes introduced regressions in non-hotspot files')
print(' Review files changed in this task. Never use eslint-disable, #[allow(...)], or as any to bypass.')
failed = True
else:
print(f'OK: Average {average:.2f} >= 9.0')
if failed:
sys.exit(1)
" || exit 1
fi
fi

View File

@@ -105,11 +105,11 @@ else
fi
# ── 5. CodeScene code health gate ────────────────────────────────────────
# Note: remote API scores lag behind local changes (only updates after push + re-analysis).
# We report the remote score for visibility but don't block on it.
# The pre-commit hook already runs `pre_commit_code_health_safeguard` locally.
# Blocks push if Hotspot < 9.5 OR Average < 9.0.
# Remote scores reflect state after last push — this catches regressions
# introduced in a previous push that weren't caught yet.
echo ""
echo "🏥 [5/5] CodeScene code health (informational — local safeguard runs in pre-commit)..."
echo "🏥 [5/5] CodeScene code health (Hotspot ≥9.5 + Average ≥9.0)..."
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
else
@@ -117,14 +117,31 @@ else
-H "Authorization: Bearer $CODESCENE_PAT" \
-H "Accept: application/json" \
"https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID" 2>/dev/null || echo "{}")
if echo "$API_RESPONSE" | python3 -c "import sys,json; json.load(sys.stdin)['analysis']" 2>/dev/null; then
HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])")
AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['code_health']['now'])")
echo " Remote Hotspot Code Health: $HOTSPOT_SCORE"
echo " Remote Average Code Health: $AVERAGE_SCORE"
echo " (remote scores update after push — local safeguard already passed in pre-commit)"
HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])" 2>/dev/null || echo "")
AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['code_health']['now'])" 2>/dev/null || echo "")
if [ -z "$HOTSPOT_SCORE" ] || [ -z "$AVERAGE_SCORE" ]; then
echo " ⚠️ Could not fetch remote scores — skipping (CI will enforce)"
else
echo " ⚠️ Could not fetch remote scores — continuing"
echo " Remote Hotspot Code Health: $HOTSPOT_SCORE (threshold: 9.5)"
echo " Remote Average Code Health: $AVERAGE_SCORE (threshold: 8.9)"
python3 -c "
import sys
hotspot = float('$HOTSPOT_SCORE')
average = float('$AVERAGE_SCORE')
failed = False
if hotspot < 9.5:
print(f'FAIL: Hotspot Code Health {hotspot:.2f} < 9.5')
failed = True
else:
print(f'OK: Hotspot {hotspot:.2f} >= 9.5')
if average < 8.9:
print(f'FAIL: Average Code Health {average:.2f} < 9.0 — regressions detected, fix before pushing')
failed = True
else:
print(f'OK: Average {average:.2f} >= 9.0')
if failed:
sys.exit(1)
" || exit 1
fi
fi

View File

@@ -8,10 +8,25 @@ pnpm test
pnpm test:coverage # frontend ≥70%
cargo test
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
pre_commit_code_health_safeguard # CodeScene ≥9.2 hotspot + ≥9.2 average (target: 9.5+)
```
If `pre_commit_code_health_safeguard` fails: extract hooks, split components, reduce complexity. Never add `// eslint-disable`, `#[allow(...)]`, or `as any` to pass the gate.
**CodeScene Code Health** — the pre-commit and pre-push hooks enforce:
- Hotspot Code Health ≥ 9.5 (most-edited files)
- Average Code Health ≥ 9.0 (project-wide, ALL files)
**Both gates block commit/push.** If either fails: extract hooks, split large components, reduce function complexity. Never add `// eslint-disable`, `#[allow(...)]`, or `as any` to pass the gate. Check both scores via MCP CodeScene after every significant change:
- `hotspot_code_health.now` ≥ 9.5
- `code_health.now` ≥ 9.0 (average — do NOT ignore this one)
If Average Code Health is below 9.0, you must fix regressions before pushing — even in files you didn't directly modify, if your changes indirectly affected complexity.
**Boy Scout Rule (Robert C. Martin):** Leave every file you touch better than you found it. When working on any task:
1. Before modifying a file, check its CodeScene health: `mcp__codescene__code_health_review`
2. If the file has issues (complexity, duplication, large functions), fix them as part of your work
3. After your changes, verify the file's score is higher than before: `mcp__codescene__code_health_score`
4. The goal: every commit either maintains or raises the overall average. No commit should lower it.
This is not optional — it's how we incrementally raise the codebase quality with every task.
## ⛔ BEFORE FIRING laputa-task-done — Two-phase QA

View File

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

View File

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

View File

@@ -342,6 +342,10 @@ interface PulseCommit {
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
- Pulls on interval, pushes after commits
- Detects merge conflicts → opens `ConflictResolverModal`
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
- Handles push rejection (divergence) → sets `pull_required` status
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
### Frontend Integration
@@ -350,6 +354,9 @@ interface PulseCommit {
- **Git history**: Shown in Inspector panel for active note
- **Commit dialog**: Triggered from sidebar or Cmd+K
- **Pulse view**: Activity feed when Pulse filter is selected
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button
- **Conflict banner**: Inline banner in editor with Keep mine / Keep theirs for conflicted notes
## BlockNote Customization
@@ -483,42 +490,29 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
`useClosedTabHistory` hook (`src/hooks/useClosedTabHistory.ts`) provides a LIFO stack for closed tab entries, used by `useTabManagement` to support Cmd+Shift+T reopen. Each entry stores the note's path, tab index, and full `VaultEntry`. The stack is in-memory only (resets on restart), capped at 20 entries, and deduplicates by path.
## Search & Indexing
## Search
### Search Modes
### Search
Keyword-based search scans all vault `.md` files using `walkdir`:
```typescript
type SearchMode = 'keyword' | 'semantic' | 'hybrid'
interface SearchResult {
title: string
path: string
snippet: string
score: number
}
interface SearchResponse {
results: SearchResult[]
elapsedMs: number
}
```
### Search Integration
`SearchPanel` component provides the search UI:
- Mode selector (keyword/semantic/hybrid)
- Real-time results as user types
- Real-time results as user types (300ms debounce)
- Click result to open note in editor
- Shows relevance score and snippet
### Indexing
Managed by `useIndexing` hook:
- Checks index status on vault load
- Two-phase indexing: scanning (parse files) → embedding (generate vectors)
- Progress streamed via Tauri events
- Incremental updates after git sync
- Metadata persisted in `.laputa-index.json`
No indexing step required — search runs directly against the filesystem.
## Vault Management
@@ -532,7 +526,7 @@ Managed by `useIndexing` hook:
### Vault Config
Per-vault settings stored in `config/ui.config.md`:
Per-vault settings stored in `ui.config.md` at vault root:
- Editable as a normal note (YAML frontmatter)
- Managed by `useVaultConfig` hook and `vaultConfigStore`
- Settings: zoom, view mode, tag colors, status colors, property display modes

View File

@@ -79,7 +79,7 @@ flowchart LR
| Frontmatter parsing | gray_matter | 0.2 |
| AI (in-app chat) | Anthropic Claude API (Haiku 3.5 default) | - |
| AI (agent panel) | Claude CLI subprocess (streaming NDJSON) | - |
| Search | qmd (keyword + semantic + hybrid) | - |
| Search | Keyword (walkdir-based file scan) | - |
| MCP | @modelcontextprotocol/sdk | 1.0 |
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
| Package manager | pnpm | - |
@@ -113,7 +113,7 @@ flowchart TD
GIT["git/"]
GH["github/"]
THEME["theme/"]
SEARCH["search.rs + indexing.rs"]
SEARCH["search.rs"]
CLI["claude_cli.rs"]
end
@@ -121,7 +121,6 @@ flowchart TD
ANTH["Anthropic API\n(Claude chat)"]
CCLI["Claude CLI\n(agent subprocess)"]
MCP["MCP Server\n(ws://9710, 9711)"]
QMD["qmd\n(search engine)"]
GHAPI["GitHub API\n(OAuth, repos, clone)"]
end
@@ -164,6 +163,24 @@ flowchart TD
Panels are separated by `ResizeHandle` components that support drag-to-resize.
## Multi-Window (Note Windows)
Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
**Triggers:**
- `Cmd+Shift+Click` on any note in the note list or sidebar
- `Cmd+K` → "Open in New Window" (command palette, requires active note)
- `Cmd+Shift+O` keyboard shortcut
- Note → "Open in New Window" menu bar item
**Architecture:**
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command)
- Secondary windows are sized 800×700 with overlay title bar
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
## AI System
Laputa has two AI interfaces with distinct architectures:
@@ -341,53 +358,16 @@ flowchart LR
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
## Search & Indexing
## Search
### Search Engine
Search is keyword-based, using `walkdir` to scan all `.md` files in the vault directory. No external binary or indexing step required.
Search uses the external `qmd` binary (semantic search engine) with three modes:
- Matches query against file titles and content (case-insensitive)
- Scores results: title matches ranked higher than content-only matches
- Extracts contextual snippets around the first match
- Skips trashed and hidden files
| Mode | Command | Description |
|------|---------|-------------|
| `keyword` | `qmd search` | Term matching (default) |
| `semantic` | `qmd vsearch` | Vector similarity search |
| `hybrid` | `qmd query` | Combined keyword + semantic |
### Indexing Flow
```mermaid
flowchart TD
A([Vault opened]) --> B[check_index_status]
B --> C{Index status?}
C -->|Fresh| D[run_incremental_update\ngit diff since last commit]
C -->|Stale / Missing| E
subgraph E[Full Indexing — start_indexing]
E1["Phase 1: qmd update\n(scan all .md files)"]
E2["Phase 2: qmd embed\n(generate vector embeddings)"]
E1 --> E2
end
E --> F[Save .laputa-index.json\nlast_indexed_commit + timestamp]
D --> G([Search ready])
F --> G
E2 -.->|failure is non-fatal| G
G --> H{Search mode}
H -->|keyword| I[qmd search]
H -->|semantic| J[qmd vsearch]
H -->|hybrid| K[qmd query]
```
Embedding failure is non-fatal — keyword search still works.
### qmd Binary Resolution
1. Bundled macOS app resource: `<app>/Contents/Resources/qmd/qmd`
2. Dev mode: `CARGO_MANIFEST_DIR/resources/qmd/qmd`
3. System locations: `~/.bun/bin/qmd`, `/usr/local/bin/qmd`, `/opt/homebrew/bin/qmd`
4. PATH lookup via `which qmd`
5. Auto-install via `bun install -g qmd` if missing
The `search_vault` Tauri command runs the scan in a blocking Tokio task and returns results sorted by relevance score.
## Vault Cache System
@@ -448,7 +428,7 @@ Managed by `useVaultSwitcher` hook. Switching vaults closes all tabs and resets
### Vault Config
Per-vault UI settings stored in `config/ui.config.md` (YAML frontmatter in a markdown note):
Per-vault UI settings stored in `ui.config.md` at vault root (YAML frontmatter in a markdown note):
- `zoom`: Float zoom level (0.81.5)
- `view_mode`: "all" | "editor-list" | "editor-only"
- `editor_mode`: "raw" | "preview" (persists across tab switches and sessions)
@@ -515,7 +495,6 @@ sequenceDiagram
VL->>T: invoke('get_modified_files')
VL->>T: useMcpStatus — register if needed
VL->>T: useThemeManager — load active theme
VL->>T: useIndexing — incremental update if stale
VL-->>A: entries ready
end
@@ -547,17 +526,34 @@ flowchart LR
flowchart TD
AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"]
PULL --> PC{Result?}
PC -->|Conflicts| CM["ConflictResolverModal"]
PC -->|Conflicts| CM["ConflictResolverModal\nor ConflictNoteBanner"]
PC -->|Fast-forward| RV["reload vault"]
PC -->|Up to date| DONE["idle"]
AS --> PUSH["invoke('git_push')"]
MAN["Manual commit\n(CommitDialog)"] --> GC["invoke('git_commit', message)"]
GC --> GP["invoke('git_push')"]
GP --> RM["Reload modified files"]
GP --> PR{Push result?}
PR -->|ok| RM["Reload modified files"]
PR -->|rejected| DIV["syncStatus = pull_required"]
DIV -->|User clicks badge| PAP["pullAndPush()"]
PAP --> PULL2["invoke('git_pull')"]
PULL2 --> GP2["invoke('git_push')"]
GP2 --> RM
CMD["Cmd+K → Pull\nor Menu → Pull"] --> PULL
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
```
#### Sync States
| State | Indicator | Color | Trigger |
|-------|-----------|-------|---------|
| `idle` | Synced / Synced Xm ago | green | Successful sync |
| `syncing` | Syncing... | blue | Pull/push in progress |
| `pull_required` | Pull required | orange | Push rejected (divergence) |
| `conflict` | Conflict | orange | Merge conflicts detected |
| `error` | Sync failed | grey | Network/auth error |
## Vault Module Structure
The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
@@ -584,8 +580,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
| `theme/` | Theme management (`mod.rs`, `create.rs`, `defaults.rs`, `seed.rs`) |
| `search.rs` | qmd search integration (keyword/semantic/hybrid) |
| `indexing.rs` | qmd indexing with progress streaming |
| `search.rs` | Keyword search — walkdir-based vault file scan |
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
| `ai_chat.rs` | Direct Anthropic API client (non-streaming, for Tauri builds) |
| `mcp.rs` | MCP server spawning + config registration |
@@ -631,6 +626,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `git_commit` | Stage all + commit |
| `git_pull` | Pull from remote |
| `git_push` | Push to remote |
| `git_remote_status` | Get branch name + ahead/behind counts |
| `git_resolve_conflict` | Resolve a merge conflict |
| `git_commit_conflict_resolution` | Commit conflict resolution |
| `get_file_history` | Last N commits for a file |
@@ -653,14 +649,11 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `github_create_repo` | Create new repo |
| `clone_repo` | Clone repo with token auth |
### Search & Indexing
### Search
| Command | Description |
|---------|-------------|
| `search_vault` | Search via qmd (keyword/semantic/hybrid) |
| `get_index_status` | Check qmd index state |
| `start_indexing` | Full index with progress streaming |
| `trigger_incremental_index` | Incremental index update |
| `search_vault` | Keyword search across vault files |
### Theme
@@ -736,7 +729,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useAIChat` | `messages`, `isStreaming` | AI chat conversation |
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useIndexing` | Index status, progress | Search indexing |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (API keys, GitHub token) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |

View File

@@ -7,7 +7,6 @@ How to navigate the codebase, run the app, and find what you need.
- **Node.js** 18+ and **pnpm**
- **Rust** 1.77.2+ (for the Tauri backend)
- **git** CLI (required by the git integration features)
- **qmd** (optional — for search indexing; auto-installed if missing)
## Quick Start
@@ -97,7 +96,7 @@ laputa-app/
│ │ ├── useEditorSave.ts # Auto-save with debounce
│ │ ├── useTheme.ts # Flatten theme.json → CSS vars
│ │ ├── useThemeManager.ts # Vault theme lifecycle
│ │ ├── useIndexing.ts # Search indexing management
│ │ ├── useUnifiedSearch.ts # Keyword search
│ │ ├── useNoteSearch.ts # Note search
│ │ ├── useCommandRegistry.ts # Command palette registry
│ │ ├── useAppCommands.ts # App-level commands
@@ -158,8 +157,7 @@ laputa-app/
│ │ │ ├── mod.rs, auth.rs, api.rs, clone.rs
│ │ ├── theme/ # Theme module
│ │ │ ├── mod.rs, create.rs, defaults.rs, seed.rs
│ │ ├── search.rs # qmd search integration
│ │ ├── indexing.rs # qmd indexing + progress streaming
│ │ ├── search.rs # Keyword search (walkdir-based)
│ │ ├── claude_cli.rs # Claude CLI subprocess management
│ │ ├── ai_chat.rs # Direct Anthropic API client
│ │ ├── mcp.rs # MCP server lifecycle + registration
@@ -220,7 +218,7 @@ laputa-app/
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). |
| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. |
| `src-tauri/src/search.rs` | qmd search integration (keyword/semantic/hybrid). |
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
### Editor

View File

@@ -1,155 +0,0 @@
#!/usr/bin/env bash
# Bundle qmd into a self-contained directory for Tauri resource embedding.
#
# Output: src-tauri/resources/qmd/
# qmd — compiled standalone binary
# node_modules/sqlite-vec/ — JS shim for sqlite-vec
# node_modules/sqlite-vec-darwin-arm64/ — native .dylib (arm64)
# node_modules/sqlite-vec-darwin-x64/ — native .dylib (x64)
# node_modules/node-llama-cpp/ — stub (keyword search only)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$SCRIPT_DIR/.."
OUT="$ROOT/src-tauri/resources/qmd"
# ---------- locate tools ----------
find_bun() {
for c in \
"$HOME/.bun/bin/bun" \
"/opt/homebrew/bin/bun" \
"/usr/local/bin/bun"; do
[[ -x "$c" ]] && { echo "$c"; return 0; }
done
command -v bun 2>/dev/null && return 0
return 1
}
BUN=$(find_bun) || { echo "ERROR: bun not found — install from https://bun.sh" >&2; exit 1; }
echo "Using bun: $BUN"
# ---------- locate qmd source ----------
# Prefer bundled source in tools/qmd/ (works in CI and dev),
# then fall back to globally installed qmd on dev machines.
QMD_SRC=""
for c in \
"$ROOT/tools/qmd" \
"$HOME/.bun/install/global/node_modules/qmd" \
"/opt/homebrew/lib/node_modules/qmd" \
"/usr/local/lib/node_modules/qmd"; do
[[ -f "$c/src/qmd.ts" ]] && { QMD_SRC="$c"; break; }
done
[[ -n "$QMD_SRC" ]] || { echo "ERROR: qmd source not found. tools/qmd/ is missing or incomplete." >&2; exit 1; }
echo "Using qmd source: $QMD_SRC"
# Install qmd dependencies if needed (for CI where node_modules don't exist yet)
if [[ ! -d "$QMD_SRC/node_modules" ]]; then
echo "Installing qmd dependencies..."
(cd "$QMD_SRC" && "$BUN" install --frozen-lockfile)
fi
# ---------- compile ----------
echo "Compiling qmd with bun build --compile..."
mkdir -p "$OUT"
(cd "$QMD_SRC" && "$BUN" build --compile \
"src/qmd.ts" \
--outfile "$OUT/qmd" \
--external node-llama-cpp \
--external sqlite-vec \
--external sqlite-vec-darwin-arm64 \
--external sqlite-vec-darwin-x64)
chmod +x "$OUT/qmd"
# ---------- bundle sqlite-vec ----------
echo "Bundling sqlite-vec native extensions..."
# Find sqlite-vec packages — prefer node_modules in QMD_SRC (after bun install),
# fall back to bun global cache for dev machines.
NM="$QMD_SRC/node_modules"
find_pkg() {
local pkg="$1"
# Check node_modules from bun install in QMD_SRC first
if [[ -d "$NM/$pkg" ]]; then
echo "$NM/$pkg"; return 0
fi
# Fall back to bun global cache
local cache_dir
cache_dir=$(find "$HOME/.bun/install/cache" -maxdepth 1 -name "${pkg}@*" -type d 2>/dev/null | head -1)
[[ -n "$cache_dir" ]] && echo "$cache_dir" && return 0
return 1
}
# sqlite-vec JS shim
SQLVEC_DIR=$(find_pkg "sqlite-vec") || { echo "ERROR: sqlite-vec not found" >&2; exit 1; }
mkdir -p "$OUT/node_modules/sqlite-vec"
cp "$SQLVEC_DIR/index.mjs" "$OUT/node_modules/sqlite-vec/index.mjs"
cp "$SQLVEC_DIR/package.json" "$OUT/node_modules/sqlite-vec/package.json"
[[ -f "$SQLVEC_DIR/index.cjs" ]] && cp "$SQLVEC_DIR/index.cjs" "$OUT/node_modules/sqlite-vec/index.cjs"
# sqlite-vec-darwin-arm64
ARM64_DIR=$(find_pkg "sqlite-vec-darwin-arm64") || true
if [[ -n "$ARM64_DIR" ]]; then
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-arm64"
cp "$ARM64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-arm64/vec0.dylib"
cp "$ARM64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-arm64/package.json"
echo " ✓ arm64 dylib"
fi
# sqlite-vec-darwin-x64
X64_DIR=$(find_pkg "sqlite-vec-darwin-x64") || true
if [[ -n "$X64_DIR" ]]; then
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-x64"
cp "$X64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-x64/vec0.dylib"
cp "$X64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-x64/package.json"
echo " ✓ x64 dylib"
fi
# ---------- stub node-llama-cpp ----------
echo "Creating node-llama-cpp stub (keyword search only)..."
mkdir -p "$OUT/node_modules/node-llama-cpp"
cat > "$OUT/node_modules/node-llama-cpp/package.json" << 'PJSON'
{"name":"node-llama-cpp","version":"0.0.0-stub","type":"module","main":"index.js"}
PJSON
cat > "$OUT/node_modules/node-llama-cpp/index.js" << 'STUB'
// Stub: node-llama-cpp not bundled — semantic search unavailable, keyword search works.
const unavailable = (name) => (...args) => {
throw new Error(`${name}() unavailable: node-llama-cpp not bundled. Keyword search still works.`);
};
export const getLlama = unavailable("getLlama");
export const resolveModelFile = unavailable("resolveModelFile");
export class LlamaChatSession {
constructor() { throw new Error("LlamaChatSession unavailable"); }
}
export const LlamaLogLevel = { Error: 0, Warn: 1, Info: 2, Debug: 3 };
STUB
# ---------- code signing (macOS) ----------
# In CI (APPLE_SIGNING_IDENTITY set): sign with Developer ID + hardened runtime (required for notarization)
# In dev (no identity): ad-hoc sign to remove quarantine
if [[ "$(uname)" == "Darwin" ]] && command -v codesign &>/dev/null; then
SIGN_ID="${APPLE_SIGNING_IDENTITY:--}"
if [[ "$SIGN_ID" != "-" ]]; then
echo "Signing bundled binaries with Developer ID: $SIGN_ID"
SIGN_OPTS=(--force --sign "$SIGN_ID" --options runtime --timestamp)
else
echo "Ad-hoc signing bundled binaries (dev mode)..."
SIGN_OPTS=(--force --sign -)
fi
codesign "${SIGN_OPTS[@]}" "$OUT/qmd" 2>/dev/null && echo " ✓ qmd signed" || echo " ⚠ qmd signing failed (non-fatal)"
while IFS= read -r -d '' dylib; do
codesign "${SIGN_OPTS[@]}" "$dylib" 2>/dev/null && echo "$(basename "$dylib") signed" || echo "$(basename "$dylib") signing failed (non-fatal)"
done < <(find "$OUT/node_modules" -name "*.dylib" -print0)
fi
# ---------- summary ----------
echo ""
echo "qmd bundled → $OUT/"
du -sh "$OUT/qmd"
du -sh "$OUT/node_modules"
echo "Done."

View File

@@ -1,13 +1,11 @@
fn main() {
// Ensure resource directories exist for the Tauri build.
// These are gitignored and populated by scripts (bundle-qmd.sh, bundle-mcp-server.mjs).
// Without a placeholder, `tauri build` / `cargo test` fails if the scripts haven't run.
for dir in ["resources/qmd", "resources/mcp-server"] {
let path = std::path::Path::new(dir);
if !path.exists() {
std::fs::create_dir_all(path).ok();
std::fs::write(path.join(".placeholder"), "").ok();
}
// Ensure resource directory exists for the Tauri build.
// Gitignored and populated by bundle-mcp-server.mjs.
// Without a placeholder, `tauri build` / `cargo test` fails if the script hasn't run.
let path = std::path::Path::new("resources/mcp-server");
if !path.exists() {
std::fs::create_dir_all(path).ok();
std::fs::write(path.join(".placeholder"), "").ok();
}
tauri_build::build()
}

View File

@@ -3,11 +3,16 @@
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
"main",
"note-*"
],
"permissions": [
"core:default",
"core:window:allow-create",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-set-title",
"core:webview:allow-create-webview-window",
"dialog:default",
"updater:default",
"process:default",

View File

@@ -6,19 +6,15 @@ use crate::claude_cli::{
};
use crate::frontmatter::FrontmatterValue;
use crate::git::{
GitCommit, GitPullResult, GitPushResult, LastCommitInfo, ModifiedFile, PulseCommit,
GitCommit, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, ModifiedFile,
PulseCommit,
};
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use crate::indexing::{IndexStatus, IndexingProgress};
use crate::search::SearchResponse;
use crate::settings::Settings;
use crate::theme::{ThemeFile, VaultSettings};
use crate::vault::{RenameResult, VaultEntry};
use crate::vault_config::VaultConfig;
use crate::vault_list::VaultList;
use crate::{
frontmatter, git, github, indexing, menu, search, theme, vault, vault_config, vault_list,
};
use crate::{frontmatter, git, github, menu, search, vault, vault_list};
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
@@ -45,20 +41,6 @@ pub fn parse_build_label(version: &str) -> String {
}
}
pub fn emit_unavailable(app_handle: &tauri::AppHandle) {
use tauri::Emitter;
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "unavailable".to_string(),
current: 0,
total: 0,
done: true,
error: Some("qmd not available".to_string()),
},
);
}
// ── Vault commands ──────────────────────────────────────────────────────────
#[tauri::command]
@@ -154,10 +136,14 @@ pub fn get_default_vault_path() -> Result<String, String> {
}
#[tauri::command]
pub fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path);
vault::invalidate_cache(std::path::Path::new(path.as_ref()));
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
pub async fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path).into_owned();
tokio::task::spawn_blocking(move || {
vault::invalidate_cache(std::path::Path::new(&path));
vault::scan_vault_cached(std::path::Path::new(&path))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
@@ -292,9 +278,11 @@ pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>
}
#[tauri::command]
pub fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
let vault_path = expand_tilde(&vault_path);
git::git_pull(&vault_path)
pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || git::git_pull(&vault_path))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
@@ -326,9 +314,19 @@ pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, Stri
}
#[tauri::command]
pub fn git_push(vault_path: String) -> Result<GitPushResult, String> {
let vault_path = expand_tilde(&vault_path);
git::git_push(&vault_path)
pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || git::git_push(&vault_path))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || git::git_remote_status(&vault_path))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
// ── GitHub commands ─────────────────────────────────────────────────────────
@@ -410,7 +408,7 @@ pub async fn stream_claude_agent(
.map_err(|e| format!("Task failed: {e}"))?
}
// ── Search & indexing commands ──────────────────────────────────────────────
// ── Search commands ─────────────────────────────────────────────────────────
#[tauri::command]
pub async fn search_vault(
@@ -426,66 +424,6 @@ pub async fn search_vault(
.map_err(|e| format!("Search task failed: {}", e))?
}
#[tauri::command]
pub fn get_index_status(vault_path: String) -> IndexStatus {
let vault_path = expand_tilde(&vault_path);
indexing::check_index_status(&vault_path)
}
#[tauri::command]
pub async fn start_indexing(
app_handle: tauri::AppHandle,
vault_path: String,
) -> Result<(), String> {
use tauri::Emitter;
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
if indexing::find_qmd_binary().is_none() {
log::info!("qmd binary not found — attempting auto-install via bun");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "installing".to_string(),
current: 0,
total: 0,
done: false,
error: None,
},
);
match indexing::try_auto_install_qmd() {
Ok(()) if indexing::find_qmd_binary().is_some() => {
log::info!("qmd auto-installed successfully, proceeding with indexing");
}
Ok(()) => {
log::warn!("qmd auto-install reported success but binary still not found");
emit_unavailable(&app_handle);
return Err("qmd not available after install".to_string());
}
Err(e) => {
log::info!("qmd auto-install failed: {e}");
emit_unavailable(&app_handle);
return Err(format!("qmd not available: {e}"));
}
}
}
indexing::run_full_index(&vault_path, |progress| {
let _ = app_handle.emit("indexing-progress", &progress);
})
})
.await
.map_err(|e| format!("Indexing task failed: {e}"))?
}
#[tauri::command]
pub async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
.await
.map_err(|e| format!("Incremental index failed: {e}"))?
}
// ── MCP commands ────────────────────────────────────────────────────────────
#[tauri::command]
@@ -503,62 +441,6 @@ pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
.map_err(|e| format!("MCP status check failed: {e}"))
}
// ── Theme commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
let vault_path = expand_tilde(&vault_path);
theme::list_themes(&vault_path)
}
#[tauri::command]
pub fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_theme(&vault_path, &theme_id)
}
#[tauri::command]
pub fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_vault_settings(&vault_path)
}
#[tauri::command]
pub fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::save_vault_settings(&vault_path, settings)
}
#[tauri::command]
pub fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::set_active_theme(&vault_path, theme_id.as_deref())
}
#[tauri::command]
pub fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_theme(&vault_path, source_id.as_deref())
}
#[tauri::command]
pub fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_vault_theme(&vault_path, name.as_deref())
}
#[tauri::command]
pub fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::ensure_vault_themes(&vault_path)
}
#[tauri::command]
pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::restore_default_themes(&vault_path)
}
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
@@ -566,11 +448,6 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
vault::migrate_is_a_to_type(&vault_path)?;
// Flatten vault: move notes from type-based subfolders to root
vault::flatten_vault(&vault_path)?;
// Remove legacy _themes/ directory (JSON theme store) if only defaults remain
theme::migrate_legacy_themes_dir(&vault_path);
// Migrate legacy theme/ directory to root, then repair themes
theme::migrate_theme_dir_to_root(&vault_path);
theme::restore_default_themes(&vault_path)?;
// Repair config files (AGENTS.md at root, config.md type def)
vault::repair_config_files(&vault_path)?;
// Ensure .gitignore with sensible defaults exists
@@ -623,18 +500,6 @@ pub fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[tauri::command]
pub fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
let vault_path = expand_tilde(&vault_path);
vault_config::get_vault_config(&vault_path)
}
#[tauri::command]
pub fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
vault_config::save_vault_config(&vault_path, config)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -787,7 +652,9 @@ mod tests {
std::fs::write(vault.join("note.md"), "---\nTrashed: true\n---\n# Note\n").unwrap();
// reload_vault must return the updated trashed state
let fresh = reload_vault(vault.to_str().unwrap().to_string()).unwrap();
let vault_str = vault.to_str().unwrap();
vault::invalidate_cache(std::path::Path::new(vault_str));
let fresh = vault::scan_vault_cached(std::path::Path::new(vault_str)).unwrap();
assert!(
fresh[0].trashed,
"reload_vault must reflect disk state after trashing"
@@ -825,7 +692,7 @@ mod tests {
}
#[test]
fn test_repair_vault_creates_config_and_theme_files() {
fn test_repair_vault_creates_config_files() {
let dir = tempfile::TempDir::new().unwrap();
let vault = dir.path();
@@ -834,14 +701,6 @@ mod tests {
// Config files at root
assert!(vault.join("AGENTS.md").exists());
assert!(vault.join("config.md").exists());
// Theme files at root (flat structure)
assert!(vault.join("default-theme.md").exists());
assert!(vault.join("dark-theme.md").exists());
assert!(vault.join("minimal-theme.md").exists());
assert!(vault.join("theme.md").exists());
// No type/themes subfolders
assert!(!vault.join("theme").exists());
assert!(!vault.join("config").exists());
// .gitignore
assert!(vault.join(".gitignore").exists());
}

View File

@@ -15,7 +15,10 @@ pub use conflict::{
};
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
pub use remote::{git_pull, git_push, has_remote, GitPullResult, GitPushResult};
pub use remote::{
git_pull, git_push, git_remote_status, has_remote, GitPullResult, GitPushResult,
GitRemoteStatus,
};
pub use status::{get_modified_files, ModifiedFile};
use serde::Serialize;

View File

@@ -110,6 +110,75 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
.collect()
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitRemoteStatus {
pub branch: String,
pub ahead: u32,
pub behind: u32,
#[serde(rename = "hasRemote")]
pub has_remote: bool,
}
/// Get the current branch name, and how many commits ahead/behind the upstream.
pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
let vault = Path::new(vault_path);
if !has_remote(vault_path)? {
let branch = current_branch(vault)?;
return Ok(GitRemoteStatus {
branch,
ahead: 0,
behind: 0,
has_remote: false,
});
}
// Fetch latest remote refs (silent, best-effort)
let _ = Command::new("git")
.args(["fetch", "--quiet"])
.current_dir(vault)
.output();
let branch = current_branch(vault)?;
let output = Command::new("git")
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git rev-list: {}", e))?;
if !output.status.success() {
// No upstream set — report 0/0
return Ok(GitRemoteStatus {
branch,
ahead: 0,
behind: 0,
has_remote: true,
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = stdout.trim().split('\t').collect();
let ahead = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let behind = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
Ok(GitRemoteStatus {
branch,
ahead,
behind,
has_remote: true,
})
}
fn current_branch(vault: &Path) -> Result<String, String> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to get branch: {}", e))?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitPushResult {
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
@@ -391,6 +460,90 @@ hint: have locally."#;
assert!(result.message.contains("Pull first"));
}
#[test]
fn test_git_remote_status_no_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
let status = git_remote_status(vp).unwrap();
assert!(!status.has_remote);
assert_eq!(status.ahead, 0);
assert_eq!(status.behind, 0);
}
#[test]
fn test_git_remote_status_up_to_date() {
let (_bare, clone_a, _clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
git_commit(vp_a, "initial").unwrap();
git_push(vp_a).unwrap();
let status = git_remote_status(vp_a).unwrap();
assert!(status.has_remote);
assert_eq!(status.ahead, 0);
assert_eq!(status.behind, 0);
}
#[test]
fn test_git_remote_status_ahead() {
let (_bare, clone_a, _clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
git_commit(vp_a, "initial").unwrap();
git_push(vp_a).unwrap();
// Make a new commit without pushing
fs::write(clone_a.path().join("note.md"), "# Updated\n").unwrap();
git_commit(vp_a, "update").unwrap();
let status = git_remote_status(vp_a).unwrap();
assert_eq!(status.ahead, 1);
assert_eq!(status.behind, 0);
}
#[test]
fn test_git_remote_status_behind() {
let (_bare, clone_a, clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
let vp_b = clone_b.path().to_str().unwrap();
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
git_commit(vp_a, "initial").unwrap();
git_push(vp_a).unwrap();
git_pull(vp_b).unwrap();
fs::write(clone_b.path().join("note.md"), "# B update\n").unwrap();
git_commit(vp_b, "from B").unwrap();
git_push(vp_b).unwrap();
// A is now behind by 1
let status = git_remote_status(vp_a).unwrap();
assert_eq!(status.behind, 1);
assert_eq!(status.ahead, 0);
}
#[test]
fn test_git_remote_status_serialization() {
let status = GitRemoteStatus {
branch: "main".to_string(),
ahead: 2,
behind: 1,
has_remote: true,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"hasRemote\""));
let parsed: GitRemoteStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.branch, "main");
assert_eq!(parsed.ahead, 2);
}
#[test]
fn test_git_pull_result_serialization() {
let result = GitPullResult {

View File

@@ -1,914 +0,0 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
/// Resolved qmd binary location: path + optional working directory.
/// The working dir is required for the bundled binary to find its node_modules.
#[derive(Debug, Clone)]
pub struct QmdBinary {
pub path: String,
pub work_dir: Option<PathBuf>,
}
impl QmdBinary {
/// Create a `Command` pre-configured with the correct working directory.
pub fn command(&self) -> Command {
let mut cmd = Command::new(&self.path);
if let Some(ref dir) = self.work_dir {
cmd.current_dir(dir);
}
cmd
}
}
static QMD_CACHE: Mutex<Option<QmdBinary>> = Mutex::new(None);
/// Locate the qmd binary, checking bundled resource first, then known locations.
/// Caches the result for subsequent calls.
pub fn find_qmd_binary() -> Option<QmdBinary> {
if let Ok(guard) = QMD_CACHE.lock() {
if let Some(ref cached) = *guard {
return Some(cached.clone());
}
}
let result = find_qmd_binary_uncached();
if let Some(ref bin) = result {
if let Ok(mut guard) = QMD_CACHE.lock() {
*guard = Some(bin.clone());
}
}
result
}
fn find_qmd_binary_uncached() -> Option<QmdBinary> {
// 1. Check bundled binary (Tauri resource)
if let Some(bin) = find_bundled_qmd() {
return Some(bin);
}
// 2. Check known system locations
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/qmd").to_string_lossy().to_string()),
Some("/usr/local/bin/qmd".to_string()),
Some("/opt/homebrew/bin/qmd".to_string()),
];
for candidate in candidates.into_iter().flatten() {
if Path::new(&candidate).exists() {
return Some(QmdBinary {
path: candidate,
work_dir: None,
});
}
}
// 3. Fallback: try PATH
Command::new("which")
.arg("qmd")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(QmdBinary {
path: String::from_utf8_lossy(&o.stdout).trim().to_string(),
work_dir: None,
})
} else {
None
}
})
}
/// Look for the bundled qmd binary inside the app bundle or dev resources.
fn find_bundled_qmd() -> Option<QmdBinary> {
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
// macOS app bundle: <app>/Contents/MacOS/laputa → <app>/Contents/Resources/qmd/qmd
let bundle_dir = exe_dir.parent()?.join("Resources").join("qmd");
if let Some(bin) = prepare_bundled_dir(&bundle_dir) {
return Some(bin);
}
// Dev mode: use compile-time CARGO_MANIFEST_DIR for reliable path resolution
let dev_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("qmd");
if let Some(bin) = prepare_bundled_dir(&dev_dir) {
return Some(bin);
}
// Dev mode fallback: walk up from exe_dir to find the project root
let mut dir = exe_dir.to_path_buf();
for _ in 0..6 {
let qmd_dir = dir.join("resources").join("qmd");
if let Some(bin) = prepare_bundled_dir(&qmd_dir) {
return Some(bin);
}
if !dir.pop() {
break;
}
}
None
}
/// Validate a bundled qmd directory and prepare the binary for execution.
/// Sets execute permissions and removes macOS quarantine attributes.
fn prepare_bundled_dir(qmd_dir: &Path) -> Option<QmdBinary> {
let qmd_path = qmd_dir.join("qmd");
if !qmd_path.exists() {
return None;
}
ensure_executable(&qmd_path);
// Remove macOS quarantine attributes that block execution of bundled binaries
#[cfg(target_os = "macos")]
{
let _ = Command::new("xattr")
.args(["-rd", "com.apple.quarantine"])
.arg(qmd_dir)
.output();
}
Some(QmdBinary {
path: qmd_path.to_string_lossy().to_string(),
work_dir: Some(qmd_dir.to_path_buf()),
})
}
/// Ensure a file has execute permission.
#[cfg(unix)]
fn ensure_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = path.metadata() {
let mode = metadata.permissions().mode();
if mode & 0o111 == 0 {
let mut perms = metadata.permissions();
perms.set_mode(mode | 0o755);
let _ = std::fs::set_permissions(path, perms);
}
}
}
#[cfg(not(unix))]
fn ensure_executable(_path: &Path) {}
/// Try to install qmd globally using bun. Returns Ok if installation succeeded.
pub fn try_auto_install_qmd() -> Result<(), String> {
let bun = find_bun().ok_or("bun not found — cannot auto-install qmd")?;
log::info!("Auto-installing qmd via bun...");
let output = Command::new(&bun)
.args(["install", "-g", "qmd"])
.output()
.map_err(|e| format!("Failed to run bun install: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("bun install -g qmd failed: {stderr}"));
}
// Clear cache so the newly installed binary is discovered
clear_qmd_cache();
log::info!("qmd auto-install succeeded");
Ok(())
}
/// Locate bun binary for auto-installing qmd.
fn find_bun() -> Option<PathBuf> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/bun")),
Some(PathBuf::from("/opt/homebrew/bin/bun")),
Some(PathBuf::from("/usr/local/bin/bun")),
];
for candidate in candidates.into_iter().flatten() {
if candidate.exists() {
return Some(candidate);
}
}
// Fallback: try PATH
Command::new("which")
.arg("bun")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(PathBuf::from(
String::from_utf8_lossy(&o.stdout).trim().to_string(),
))
} else {
None
}
})
}
/// Clear the cached qmd binary (e.g. after path changes or installation).
pub fn clear_qmd_cache() {
if let Ok(mut guard) = QMD_CACHE.lock() {
*guard = None;
}
}
#[derive(Debug, Serialize, Clone)]
pub struct IndexStatus {
pub available: bool,
pub qmd_installed: bool,
pub collection_exists: bool,
pub indexed_count: usize,
pub embedded_count: usize,
pub pending_embed: usize,
pub last_indexed_commit: Option<String>,
pub last_indexed_at: Option<u64>,
}
// --- Index metadata persistence ---
#[derive(Debug, Serialize, Deserialize, Default)]
struct IndexMetadata {
#[serde(default)]
last_indexed_commit: Option<String>,
#[serde(default)]
last_indexed_at: Option<u64>,
}
fn index_metadata_path(vault_path: &str) -> PathBuf {
Path::new(vault_path).join(".laputa-index.json")
}
fn load_index_metadata(vault_path: &str) -> IndexMetadata {
let path = index_metadata_path(vault_path);
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_index_metadata(vault_path: &str, meta: &IndexMetadata) -> Result<(), String> {
let path = index_metadata_path(vault_path);
let json =
serde_json::to_string_pretty(meta).map_err(|e| format!("Failed to serialize: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Failed to write index metadata: {e}"))
}
/// Get the current HEAD commit hash for a vault.
fn get_head_commit(vault_path: &str) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(vault_path)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
/// Record the current HEAD as the last indexed commit.
fn stamp_index_commit(vault_path: &str) {
if let Some(commit) = get_head_commit(vault_path) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let meta = IndexMetadata {
last_indexed_commit: Some(commit),
last_indexed_at: Some(now),
};
let _ = save_index_metadata(vault_path, &meta);
}
}
/// Check whether the vault has a qmd index and its status.
pub fn check_index_status(vault_path: &str) -> IndexStatus {
let meta = load_index_metadata(vault_path);
let qmd = match find_qmd_binary() {
Some(b) => b,
None => {
return IndexStatus {
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
last_indexed_commit: meta.last_indexed_commit,
last_indexed_at: meta.last_indexed_at,
}
}
};
let vault_name = vault_dir_name(vault_path);
let output = qmd.command().args(["status"]).output();
let mut status = match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
parse_status_for_vault(&stdout, &vault_name)
}
_ => IndexStatus {
available: false,
qmd_installed: true,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
last_indexed_commit: None,
last_indexed_at: None,
},
};
status.last_indexed_commit = meta.last_indexed_commit;
status.last_indexed_at = meta.last_indexed_at;
status
}
fn vault_dir_name(vault_path: &str) -> String {
Path::new(vault_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("laputa")
.to_lowercase()
}
fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus {
let mut collection_exists = false;
let mut indexed_count = 0;
let mut embedded_count = 0;
let mut pending_embed = 0;
// Look for collection section matching vault name
let mut in_vault_section = false;
for line in status_output.lines() {
let trimmed = line.trim();
// Collection headers look like: " laputa (qmd://laputa/)"
if trimmed.contains(&format!("qmd://{vault_name}/")) {
collection_exists = true;
in_vault_section = true;
continue;
}
// New collection section starts
if trimmed.contains("qmd://") && !trimmed.contains(vault_name) {
in_vault_section = false;
continue;
}
if in_vault_section {
if let Some(count_str) = extract_count_from_line(trimmed, "Files:") {
indexed_count = count_str;
}
}
}
// Global counts from the Documents section
for line in status_output.lines() {
let trimmed = line.trim();
if trimmed.starts_with("Total:") {
if let Some(n) = extract_first_number(trimmed) {
if embedded_count == 0 && indexed_count == 0 {
indexed_count = n;
}
}
} else if trimmed.starts_with("Vectors:") {
if let Some(n) = extract_first_number(trimmed) {
embedded_count = n;
}
} else if trimmed.starts_with("Pending:") {
if let Some(n) = extract_first_number(trimmed) {
pending_embed = n;
}
}
}
IndexStatus {
available: true,
qmd_installed: true,
collection_exists,
indexed_count,
embedded_count,
pending_embed,
last_indexed_commit: None,
last_indexed_at: None,
}
}
fn extract_count_from_line(line: &str, prefix: &str) -> Option<usize> {
if !line.starts_with(prefix) {
return None;
}
extract_first_number(line)
}
fn extract_first_number(s: &str) -> Option<usize> {
s.split_whitespace()
.find_map(|word| word.parse::<usize>().ok())
}
/// Ensure a qmd collection exists for this vault. Creates one if missing.
pub fn ensure_collection(vault_path: &str) -> Result<(), String> {
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
let vault_name = vault_dir_name(vault_path);
// Check if collection already exists
let output = qmd
.command()
.args(["collection", "list"])
.output()
.map_err(|e| format!("Failed to list collections: {e}"))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains(&format!("qmd://{vault_name}/")) {
return Ok(());
}
}
// Create collection
qmd.command()
.args([
"collection",
"add",
vault_path,
"--name",
&vault_name,
"--mask",
"**/*.md",
])
.output()
.map_err(|e| format!("Failed to create collection: {e}"))?;
Ok(())
}
#[derive(Debug, Serialize, Clone)]
pub struct IndexingProgress {
pub phase: String,
pub current: usize,
pub total: usize,
pub done: bool,
pub error: Option<String>,
}
/// Run full indexing: update + embed. Returns progress updates via callback.
pub fn run_full_index<F>(vault_path: &str, on_progress: F) -> Result<(), String>
where
F: Fn(IndexingProgress),
{
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
ensure_collection(vault_path)?;
let vault_name = vault_dir_name(vault_path);
// Phase 1: update (scan files) — scoped to this vault's collection only
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: 0,
total: 0,
done: false,
error: None,
});
let update_output = qmd
.command()
.args(["update", &vault_name])
.output()
.map_err(|e| format!("qmd update failed: {e}"))?;
if !update_output.status.success() {
let stderr = String::from_utf8_lossy(&update_output.stderr);
let err = format!("qmd update failed: {stderr}");
on_progress(IndexingProgress {
phase: "error".to_string(),
current: 0,
total: 0,
done: true,
error: Some(err.clone()),
});
return Err(err);
}
// Parse update output for counts
let update_stdout = String::from_utf8_lossy(&update_output.stdout);
let total = parse_indexed_count(&update_stdout);
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: total,
total,
done: false,
error: None,
});
// Phase 2: embed (generate vectors) — scoped to this vault's collection only
on_progress(IndexingProgress {
phase: "embedding".to_string(),
current: 0,
total,
done: false,
error: None,
});
let embed_output = qmd
.command()
.args(["embed", "-c", &vault_name])
.output()
.map_err(|e| format!("qmd embed failed: {e}"))?;
if !embed_output.status.success() {
let stderr = String::from_utf8_lossy(&embed_output.stderr);
// Embedding failure is non-fatal — keyword search still works
log::warn!("qmd embed failed (keyword search still works): {stderr}");
stamp_index_commit(vault_path);
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
total,
done: true,
error: Some("Embedding failed — keyword search only".to_string()),
});
return Ok(());
}
stamp_index_commit(vault_path);
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
total,
done: true,
error: None,
});
Ok(())
}
fn parse_indexed_count(update_output: &str) -> usize {
// qmd update output typically contains lines like "Indexed 9078 files"
for line in update_output.lines() {
if let Some(n) = extract_first_number(line) {
return n;
}
}
0
}
/// Run incremental update for a single file change.
pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
// Verify collection exists
let vault_name = vault_dir_name(vault_path);
let list_output = qmd
.command()
.args(["collection", "list"])
.output()
.map_err(|e| format!("Failed to list collections: {e}"))?;
if list_output.status.success() {
let stdout = String::from_utf8_lossy(&list_output.stdout);
if !stdout.contains(&format!("qmd://{vault_name}/")) {
// Collection doesn't exist yet — skip incremental, full index needed
return Ok(());
}
}
let output = qmd
.command()
.args(["update", &vault_name])
.output()
.map_err(|e| format!("qmd incremental update failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("qmd update failed: {stderr}"));
}
stamp_index_commit(vault_path);
Ok(())
}
/// Check if HEAD has advanced past the last indexed commit.
#[cfg(test)]
fn needs_reindex_after_sync(vault_path: &str) -> bool {
let meta = load_index_metadata(vault_path);
let head = get_head_commit(vault_path);
match (meta.last_indexed_commit, head) {
(Some(last), Some(current)) => last != current,
(None, Some(_)) => true,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qmd_binary_command_sets_work_dir() {
let qmd = QmdBinary {
path: "/bin/echo".to_string(),
work_dir: Some(PathBuf::from("/tmp")),
};
let cmd = qmd.command();
let dbg = format!("{:?}", cmd);
assert!(dbg.contains("/bin/echo"));
}
#[test]
fn qmd_binary_command_no_work_dir() {
let qmd = QmdBinary {
path: "/bin/echo".to_string(),
work_dir: None,
};
let output = qmd.command().arg("hello").output().unwrap();
assert!(output.status.success());
}
#[test]
fn vault_dir_name_extracts_last_segment() {
assert_eq!(vault_dir_name("/Users/luca/Laputa"), "laputa");
assert_eq!(vault_dir_name("/home/user/MyVault"), "myvault");
}
#[test]
fn vault_dir_name_fallback() {
assert_eq!(vault_dir_name(""), "laputa");
}
#[test]
fn extract_first_number_works() {
assert_eq!(
extract_first_number("Total: 9078 files indexed"),
Some(9078)
);
assert_eq!(extract_first_number("Vectors: 14676 embedded"), Some(14676));
assert_eq!(extract_first_number("no numbers here"), None);
}
#[test]
fn parse_status_finds_collection() {
let status = r#"
QMD Status
Index: /Users/luca/.cache/qmd/index.sqlite
Size: 100.9 MB
Documents
Total: 9115 files indexed
Vectors: 14676 embedded
Pending: 26 need embedding
Collections
laputa (qmd://laputa/)
Pattern: **/*.md
Files: 9078 (updated 20d ago)
"#;
let result = parse_status_for_vault(status, "laputa");
assert!(result.collection_exists);
assert_eq!(result.indexed_count, 9078);
assert_eq!(result.embedded_count, 14676);
assert_eq!(result.pending_embed, 26);
}
#[test]
fn parse_status_missing_collection() {
let status = r#"
QMD Status
Documents
Total: 100 files indexed
Vectors: 50 embedded
Pending: 0
Collections
other (qmd://other/)
Files: 100
"#;
let result = parse_status_for_vault(status, "laputa");
assert!(!result.collection_exists);
}
#[test]
fn extract_count_from_line_works() {
assert_eq!(
extract_count_from_line("Files: 9078 (updated 20d ago)", "Files:"),
Some(9078)
);
assert_eq!(extract_count_from_line("Pattern: **/*.md", "Files:"), None);
}
#[test]
fn parse_indexed_count_from_output() {
assert_eq!(parse_indexed_count("Indexed 342 files in 1.2s"), 342);
assert_eq!(parse_indexed_count("No output"), 0);
}
#[test]
fn ensure_executable_sets_permission() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test-bin");
fs::write(&file, "#!/bin/sh\necho ok").unwrap();
// Start with no execute permission
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0);
ensure_executable(&file);
assert_ne!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0);
}
}
#[test]
fn ensure_executable_noop_when_already_executable() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test-bin");
fs::write(&file, "#!/bin/sh\necho ok").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap();
ensure_executable(&file);
let mode = fs::metadata(&file).unwrap().permissions().mode();
assert_ne!(mode & 0o111, 0);
}
}
#[test]
fn prepare_bundled_dir_returns_none_for_missing_binary() {
let dir = tempfile::tempdir().unwrap();
assert!(prepare_bundled_dir(dir.path()).is_none());
}
#[test]
fn prepare_bundled_dir_finds_and_prepares_binary() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let qmd_path = dir.path().join("qmd");
fs::write(&qmd_path, "#!/bin/sh\necho ok").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&qmd_path, fs::Permissions::from_mode(0o644)).unwrap();
}
let result = prepare_bundled_dir(dir.path());
assert!(result.is_some());
let bin = result.unwrap();
assert!(bin.path.ends_with("qmd"));
assert_eq!(bin.work_dir.unwrap(), dir.path());
// Verify execute permission was set
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_ne!(
fs::metadata(&qmd_path).unwrap().permissions().mode() & 0o111,
0
);
}
}
#[test]
fn find_bun_returns_some_if_available() {
// This test may succeed or fail depending on the system.
// It verifies the function doesn't panic.
let _ = find_bun();
}
#[test]
fn index_metadata_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Default when no file exists
let meta = load_index_metadata(vault);
assert!(meta.last_indexed_commit.is_none());
assert!(meta.last_indexed_at.is_none());
// Write and read back
let meta = IndexMetadata {
last_indexed_commit: Some("abc123def456".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
let loaded = load_index_metadata(vault);
assert_eq!(loaded.last_indexed_commit.as_deref(), Some("abc123def456"));
assert_eq!(loaded.last_indexed_at, Some(1709000000));
}
#[test]
fn index_metadata_survives_malformed_json() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Write garbage
std::fs::write(dir.path().join(".laputa-index.json"), "not json").unwrap();
let meta = load_index_metadata(vault);
assert!(meta.last_indexed_commit.is_none());
}
#[test]
fn needs_reindex_after_sync_no_metadata() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Init a git repo so get_head_commit works
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
// No metadata → needs reindex
assert!(needs_reindex_after_sync(vault));
}
#[test]
fn needs_reindex_after_sync_same_commit() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
let head = get_head_commit(vault).unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some(head),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
assert!(!needs_reindex_after_sync(vault));
}
#[test]
fn needs_reindex_after_sync_different_commit() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some("old_commit_hash".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
assert!(needs_reindex_after_sync(vault));
}
#[test]
fn check_index_status_includes_metadata() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some("abc123".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
let status = check_index_status(vault);
assert_eq!(status.last_indexed_commit.as_deref(), Some("abc123"));
assert_eq!(status.last_indexed_at, Some(1709000000));
}
}

View File

@@ -4,14 +4,11 @@ mod commands;
pub mod frontmatter;
pub mod git;
pub mod github;
pub mod indexing;
pub mod mcp;
pub mod menu;
pub mod search;
pub mod settings;
pub mod theme;
pub mod vault;
pub mod vault_config;
pub mod vault_list;
use std::process::Child;
@@ -44,20 +41,6 @@ fn run_startup_tasks() {
"Migrated is_a to type on startup",
vault::migrate_is_a_to_type(vp_str),
);
log_startup_result(
"Migrated hidden_sections to visible property",
vault_config::migrate_hidden_sections_to_visible(vp_str),
);
// Remove legacy _themes/ directory (JSON theme store) if only defaults remain
theme::migrate_legacy_themes_dir(vp_str);
// Migrate legacy theme/ directory notes to root (flat structure)
theme::migrate_theme_dir_to_root(vp_str);
// Seed vault theme notes at root (flat structure) if missing
theme::seed_vault_themes(vp_str);
// Seed theme.md type definition so the Theme type has an icon in the sidebar
let _ = theme::ensure_theme_type_definition(vp_str);
// Migrate legacy config/agents.md → root AGENTS.md (one-time, idempotent)
vault::migrate_agents_md(vp_str);
// Seed AGENTS.md and config.md at vault root if missing
@@ -130,6 +113,7 @@ pub fn run() {
commands::get_last_commit_info,
commands::git_pull,
commands::git_push,
commands::git_remote_status,
commands::get_conflict_files,
commands::get_conflict_mode,
commands::git_resolve_conflict,
@@ -164,26 +148,12 @@ pub fn run() {
commands::github_device_flow_poll,
commands::github_get_user,
commands::search_vault,
commands::get_index_status,
commands::start_indexing,
commands::trigger_incremental_index,
commands::create_getting_started_vault,
commands::check_vault_exists,
commands::get_default_vault_path,
commands::register_mcp_tools,
commands::check_mcp_status,
commands::list_themes,
commands::get_theme,
commands::get_vault_settings,
commands::save_vault_settings,
commands::set_active_theme,
commands::create_theme,
commands::create_vault_theme,
commands::ensure_vault_themes,
commands::restore_default_themes,
commands::repair_vault,
commands::get_vault_config,
commands::save_vault_config
commands::repair_vault
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View File

@@ -36,21 +36,21 @@ const GO_ALL_NOTES: &str = "go-all-notes";
const GO_ARCHIVED: &str = "go-archived";
const GO_TRASH: &str = "go-trash";
const GO_CHANGES: &str = "go-changes";
const GO_INBOX: &str = "go-inbox";
const NOTE_ARCHIVE: &str = "note-archive";
const NOTE_TRASH: &str = "note-trash";
const NOTE_EMPTY_TRASH: &str = "note-empty-trash";
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
const VAULT_OPEN: &str = "vault-open";
const VAULT_REMOVE: &str = "vault-remove";
const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started";
const VAULT_NEW_THEME: &str = "vault-new-theme";
const VAULT_RESTORE_DEFAULT_THEMES: &str = "vault-restore-default-themes";
const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
const VAULT_PULL: &str = "vault-pull";
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
const VAULT_REINDEX: &str = "vault-reindex";
const VAULT_RELOAD: &str = "vault-reload";
const VAULT_REPAIR: &str = "vault-repair";
@@ -86,16 +86,15 @@ const CUSTOM_IDS: &[&str] = &[
NOTE_ARCHIVE,
NOTE_TRASH,
NOTE_EMPTY_TRASH,
NOTE_OPEN_IN_NEW_WINDOW,
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
VAULT_NEW_THEME,
VAULT_RESTORE_DEFAULT_THEMES,
VAULT_COMMIT_PUSH,
VAULT_PULL,
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
VAULT_RELOAD,
VAULT_REPAIR,
];
@@ -109,6 +108,7 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_TOGGLE_BACKLINKS,
NOTE_OPEN_IN_NEW_WINDOW,
];
/// IDs of menu items that depend on having uncommitted changes.
@@ -267,6 +267,7 @@ fn build_go_menu(app: &App) -> MenuResult {
.build(app)?;
let trash = MenuItemBuilder::new("Trash").id(GO_TRASH).build(app)?;
let changes = MenuItemBuilder::new("Changes").id(GO_CHANGES).build(app)?;
let inbox = MenuItemBuilder::new("Inbox").id(GO_INBOX).build(app)?;
let go_back = MenuItemBuilder::new("Go Back")
.id(VIEW_GO_BACK)
.accelerator("CmdOrCtrl+[")
@@ -281,6 +282,7 @@ fn build_go_menu(app: &App) -> MenuResult {
.item(&archived)
.item(&trash)
.item(&changes)
.item(&inbox)
.separator()
.item(&go_back)
.item(&go_forward)
@@ -299,6 +301,10 @@ fn build_note_menu(app: &App) -> MenuResult {
let empty_trash = MenuItemBuilder::new("Empty Trash…")
.id(NOTE_EMPTY_TRASH)
.build(app)?;
let open_new_window = MenuItemBuilder::new("Open in New Window")
.id(NOTE_OPEN_IN_NEW_WINDOW)
.accelerator("CmdOrCtrl+Shift+O")
.build(app)?;
let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor")
.id(EDIT_TOGGLE_RAW_EDITOR)
.accelerator("CmdOrCtrl+\\")
@@ -316,6 +322,8 @@ fn build_note_menu(app: &App) -> MenuResult {
.item(&trash_note)
.item(&empty_trash)
.separator()
.item(&open_new_window)
.separator()
.item(&toggle_raw_editor)
.item(&toggle_ai_chat)
.item(&toggle_backlinks)
@@ -332,15 +340,12 @@ fn build_vault_menu(app: &App) -> MenuResult {
let restore_getting_started = MenuItemBuilder::new("Restore Getting Started")
.id(VAULT_RESTORE_GETTING_STARTED)
.build(app)?;
let new_theme = MenuItemBuilder::new("New Theme")
.id(VAULT_NEW_THEME)
.build(app)?;
let restore_default_themes = MenuItemBuilder::new("Restore Default Themes")
.id(VAULT_RESTORE_DEFAULT_THEMES)
.build(app)?;
let commit_push = MenuItemBuilder::new("Commit & Push")
.id(VAULT_COMMIT_PUSH)
.build(app)?;
let pull = MenuItemBuilder::new("Pull from Remote")
.id(VAULT_PULL)
.build(app)?;
let resolve_conflicts = MenuItemBuilder::new("Resolve Conflicts")
.id(VAULT_RESOLVE_CONFLICTS)
.enabled(false)
@@ -351,9 +356,6 @@ fn build_vault_menu(app: &App) -> MenuResult {
let install_mcp = MenuItemBuilder::new("Restore MCP Server")
.id(VAULT_INSTALL_MCP)
.build(app)?;
let reindex = MenuItemBuilder::new("Reindex Vault")
.id(VAULT_REINDEX)
.build(app)?;
let reload = MenuItemBuilder::new("Reload Vault")
.id(VAULT_RELOAD)
.build(app)?;
@@ -366,14 +368,11 @@ fn build_vault_menu(app: &App) -> MenuResult {
.item(&remove_vault)
.item(&restore_getting_started)
.separator()
.item(&new_theme)
.item(&restore_default_themes)
.separator()
.item(&commit_push)
.item(&pull)
.item(&resolve_conflicts)
.item(&view_changes)
.separator()
.item(&reindex)
.item(&reload)
.item(&repair)
.item(&install_mcp)
@@ -485,16 +484,15 @@ mod tests {
NOTE_ARCHIVE,
NOTE_TRASH,
NOTE_EMPTY_TRASH,
NOTE_OPEN_IN_NEW_WINDOW,
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
VAULT_NEW_THEME,
VAULT_RESTORE_DEFAULT_THEMES,
VAULT_COMMIT_PUSH,
VAULT_PULL,
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
VAULT_RELOAD,
];
for id in &expected {

View File

@@ -1,12 +1,10 @@
use crate::indexing;
use crate::vault;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
use std::time::Instant;
use walkdir::WalkDir;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Clone)]
pub struct SearchResult {
pub title: String,
pub path: String,
@@ -23,159 +21,107 @@ pub struct SearchResponse {
pub mode: String,
}
#[derive(Debug, Deserialize)]
struct QmdResult {
pub file: String,
pub title: String,
pub snippet: String,
pub score: f64,
}
fn qmd_uri_to_vault_path(uri: &str, vault_path: &str) -> String {
// qmd://laputa/essay/foo.md → essay/foo.md
let relative = uri
.strip_prefix("qmd://")
.and_then(|s| s.find('/').map(|i| &s[i + 1..]))
.unwrap_or(uri);
format!("{}/{}", vault_path, relative)
}
fn extract_clean_snippet(raw_snippet: &str) -> String {
// qmd snippets start with "@@ -N,N @@ (N before, N after)\n"
// We want just the content lines
let lines: Vec<&str> = raw_snippet.lines().collect();
let content_start = lines.iter().position(|l| !l.starts_with("@@")).unwrap_or(0);
let content: String = lines[content_start..]
.iter()
.filter(|l| !l.starts_with("---"))
.take(3)
.copied()
.collect::<Vec<&str>>()
.join(" ");
// Trim to reasonable length
if content.len() > 200 {
format!("{}...", &content[..200])
} else {
content
}
}
static COLLECTION_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
fn detect_collection_name(vault_path: &str) -> String {
// Check cache first
if let Ok(guard) = COLLECTION_CACHE.lock() {
if let Some(ref cache) = *guard {
if let Some(name) = cache.get(vault_path) {
return name.clone();
}
}
}
let result = detect_collection_name_uncached(vault_path);
// Store in cache
if let Ok(mut guard) = COLLECTION_CACHE.lock() {
let cache = guard.get_or_insert_with(HashMap::new);
cache.insert(vault_path.to_string(), result.clone());
}
result
}
fn detect_collection_name_uncached(vault_path: &str) -> String {
let qmd = match indexing::find_qmd_binary() {
Some(b) => b,
None => return "laputa".to_string(),
fn extract_snippet(content: &str, query_lower: &str) -> String {
let content_lower = content.to_lowercase();
let pos = match content_lower.find(query_lower) {
Some(p) => p,
None => return String::new(),
};
let output = qmd.command().args(["collection", "list"]).output();
match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
let vault_name = Path::new(vault_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("laputa")
.to_lowercase();
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.contains(&vault_name) && trimmed.contains("qmd://") {
if let Some(name) = trimmed.split_whitespace().next() {
return name.to_string();
}
}
}
vault_name
}
_ => Path::new(vault_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("laputa")
.to_lowercase(),
let start = content[..pos]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(pos.saturating_sub(60));
let end = content[pos..]
.find('\n')
.map(|i| pos + i)
.unwrap_or_else(|| (pos + 120).min(content.len()));
let snippet = &content[start..end];
if snippet.len() > 200 {
format!("{}", &snippet[..200])
} else {
snippet.to_string()
}
}
fn score_match(title_lower: &str, content_lower: &str, query_lower: &str) -> f64 {
let title_exact = title_lower.contains(query_lower);
let title_word = title_lower.split_whitespace().any(|w| w == query_lower);
let content_count = content_lower.matches(query_lower).count();
let mut score = 0.0;
if title_word {
score += 10.0;
} else if title_exact {
score += 5.0;
}
score += (content_count as f64).min(20.0) * 0.5;
score
}
pub fn search_vault(
vault_path: &str,
query: &str,
mode: &str,
_mode: &str,
limit: usize,
) -> Result<SearchResponse, String> {
let start = Instant::now();
let query_lower = query.to_lowercase();
let vault_dir = Path::new(vault_path);
let qmd = indexing::find_qmd_binary().ok_or_else(|| "qmd binary not found".to_string())?;
let mut results: Vec<SearchResult> = Vec::new();
let collection = detect_collection_name(vault_path);
for entry in WalkDir::new(vault_dir).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.extension().is_some_and(|ext| ext == "md") {
continue;
}
if vault::is_file_trashed(path) {
continue;
}
// Skip hidden dirs and .laputa config
if path
.components()
.any(|c| c.as_os_str().to_string_lossy().starts_with('.'))
{
continue;
}
let search_cmd = match mode {
"semantic" => "vsearch",
"hybrid" => "query",
_ => "search", // "keyword" default
};
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let limit_str = limit.to_string();
let output = qmd
.command()
.args([
search_cmd,
query,
"--collection",
&collection,
"--json",
"-n",
&limit_str,
])
.output()
.map_err(|e| format!("Failed to run qmd: {}", e))?;
let content_lower = content.to_lowercase();
let title = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let title_lower = title.to_lowercase();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("qmd search failed: {}", stderr));
if !title_lower.contains(&query_lower) && !content_lower.contains(&query_lower) {
continue;
}
let score = score_match(&title_lower, &content_lower, &query_lower);
let snippet = extract_snippet(&content, &query_lower);
let full_path = path.to_string_lossy().to_string();
results.push(SearchResult {
title,
path: full_path,
snippet,
score,
note_type: None,
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let qmd_results: Vec<QmdResult> =
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse qmd output: {}", e))?;
let results: Vec<SearchResult> = qmd_results
.into_iter()
.filter_map(|r| {
let path = qmd_uri_to_vault_path(&r.file, vault_path);
if vault::is_file_trashed(Path::new(&path)) {
return None;
}
let snippet = extract_clean_snippet(&r.snippet);
Some(SearchResult {
title: r.title,
path,
snippet,
score: r.score,
note_type: None,
})
})
.collect();
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(limit);
let elapsed_ms = start.elapsed().as_millis() as u64;
@@ -183,7 +129,7 @@ pub fn search_vault(
results,
elapsed_ms,
query: query.to_string(),
mode: mode.to_string(),
mode: "keyword".to_string(),
})
}
@@ -192,59 +138,36 @@ mod tests {
use super::*;
#[test]
fn test_qmd_uri_to_vault_path() {
assert_eq!(
qmd_uri_to_vault_path("qmd://laputa/essay/foo.md", "/Users/luca/Laputa"),
"/Users/luca/Laputa/essay/foo.md"
);
assert_eq!(
qmd_uri_to_vault_path(
"qmd://laputa/event/2025-10-15-retreat.md",
"/Users/luca/Laputa"
),
"/Users/luca/Laputa/event/2025-10-15-retreat.md"
);
fn test_extract_snippet_basic() {
let content = "line one\nline with keyword here\nline three";
let snippet = extract_snippet(content, "keyword");
assert!(snippet.contains("keyword"));
}
#[test]
fn test_extract_clean_snippet() {
let raw = "@@ -2,4 @@ (1 before, 9 after)\naliases:\n - \"Refactoring Retreat\"\n\"Is A\":\n - Event";
let clean = extract_clean_snippet(raw);
assert!(clean.starts_with("aliases:"));
assert!(clean.contains("Refactoring Retreat"));
fn test_extract_snippet_no_match() {
let snippet = extract_snippet("nothing here", "missing");
assert!(snippet.is_empty());
}
#[test]
fn test_extract_clean_snippet_long() {
let raw = format!("@@ -1,1 @@\n{}", "a".repeat(300));
let clean = extract_clean_snippet(&raw);
assert!(clean.len() <= 203); // 200 + "..."
assert!(clean.ends_with("..."));
fn test_score_match_title_word() {
let score = score_match("my keyword", "", "keyword");
assert!(score >= 10.0);
}
#[test]
fn test_detect_collection_fallback() {
// With a non-existent vault path, should return either the lowercase dir name
// (if qmd is available and collection list succeeds) or "laputa" (if qmd is not installed).
// Both are valid fallbacks — this test verifies the function doesn't panic.
let name = detect_collection_name("/tmp/test-vault");
assert!(
name == "test-vault" || name == "laputa",
"Expected 'test-vault' or 'laputa', got '{}'",
name
);
}
#[test]
fn test_qmd_uri_fallback() {
// Covers fallback branch when URI doesn't start with "qmd://"
let result = qmd_uri_to_vault_path("invalid-uri", "/vault");
assert!(result.contains("invalid-uri"));
fn test_score_match_content_only() {
let score = score_match("unrelated", "some keyword text keyword", "keyword");
assert!(score > 0.0);
assert!(score < 10.0);
}
#[test]
fn test_extract_clean_snippet_no_header() {
// No @@ header — content_start = 0
let snippet = extract_clean_snippet("plain content line");
assert_eq!(snippet, "plain content line");
fn test_extract_snippet_long() {
let long_line = "a".repeat(300);
let content = format!("start\n{}keyword{}\nend", long_line, long_line);
let snippet = extract_snippet(&content, "keyword");
assert!(snippet.len() <= 203); // 200 + "…" (3 bytes UTF-8)
}
}

View File

@@ -1,302 +0,0 @@
use std::fs;
use std::path::Path;
use super::defaults::DEFAULT_VAULT_THEME_VARS;
/// Create a new vault theme note at vault root (flat structure).
/// Returns the absolute path to the newly created theme note.
pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result<String, String> {
let vault_dir = Path::new(vault_path);
let display_name = name.unwrap_or("Untitled Theme");
let slug = slugify(display_name);
let filename = format!("{}.md", find_available_stem(vault_dir, &slug, "md"));
let path = vault_dir.join(&filename);
let content = vault_theme_note_content(display_name, &DEFAULT_VAULT_THEME_VARS);
fs::write(&path, content).map_err(|e| format!("Failed to write theme note: {e}"))?;
Ok(path.to_string_lossy().to_string())
}
/// Create a new theme file by copying the active theme (or default).
/// Returns the ID of the new theme.
/// NOTE: Legacy — operates on `_themes/` JSON store. Prefer `create_vault_theme`.
pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String, String> {
let themes_dir = Path::new(vault_path).join("_themes");
if !themes_dir.is_dir() {
return Err("Legacy _themes/ directory not found — use vault themes instead".to_string());
}
let new_id = find_available_stem(&themes_dir, "untitled", "json");
let source = source_id.unwrap_or("default");
let source_path = themes_dir.join(format!("{source}.json"));
let content = if source_path.exists() {
let mut theme: serde_json::Value = serde_json::from_str(
&fs::read_to_string(&source_path)
.map_err(|e| format!("Failed to read source theme: {e}"))?,
)
.map_err(|e| format!("Failed to parse source theme: {e}"))?;
if let Some(obj) = theme.as_object_mut() {
obj.insert(
"name".to_string(),
serde_json::Value::String("Untitled Theme".to_string()),
);
}
serde_json::to_string_pretty(&theme)
.map_err(|e| format!("Failed to serialize new theme: {e}"))?
} else {
default_theme_json("Untitled Theme")
};
fs::write(themes_dir.join(format!("{new_id}.json")), content)
.map_err(|e| format!("Failed to write new theme: {e}"))?;
Ok(new_id)
}
/// Convert a display name to a URL-safe slug.
fn slugify(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Find an available filename stem (base, base-2, base-3, …) that doesn't
/// conflict when `ext` is appended.
fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String {
if !dir.join(format!("{base}.{ext}")).exists() {
return base.to_string();
}
for i in 2.. {
let candidate = format!("{base}-{i}");
if !dir.join(format!("{candidate}.{ext}")).exists() {
return candidate;
}
}
unreachable!()
}
/// Build a vault theme note markdown string from a name and CSS variable map.
fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String {
let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n");
for (key, value) in vars {
if value.contains('#') || value.contains('\'') || value.contains(',') || value.contains('(')
{
fm.push_str(&format!("{key}: \"{value}\"\n"));
} else {
fm.push_str(&format!("{key}: {value}\n"));
}
}
fm.push_str("---\n\n");
fm.push_str(&format!(
"# {name} Theme\n\nA custom {name} theme for Laputa.\n"
));
fm
}
/// Generate the default light theme JSON.
fn default_theme_json(name: &str) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"name": name,
"description": "Custom theme",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"sidebar-background": "#F7F6F3",
"accent": "#155DFF",
"muted": "#787774",
"border": "#E9E9E7"
},
"typography": {
"font-family": "system-ui",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "240px"
}
}))
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::defaults::*;
use crate::theme::get_theme;
use std::fs;
use tempfile::TempDir;
fn setup_vault_with_themes(dir: &TempDir) -> String {
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
vault.to_string_lossy().to_string()
}
#[test]
fn test_create_theme_copies_source() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let new_id = create_theme(&vault, Some("default")).unwrap();
assert_eq!(new_id, "untitled");
let theme = get_theme(&vault, &new_id).unwrap();
assert_eq!(theme.name, "Untitled Theme");
assert!(!theme.colors.is_empty());
}
#[test]
fn test_create_theme_increments_id() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let id1 = create_theme(&vault, None).unwrap();
assert_eq!(id1, "untitled");
let id2 = create_theme(&vault, None).unwrap();
assert_eq!(id2, "untitled-2");
}
#[test]
fn test_create_vault_theme_creates_md_file() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, Some("My Theme")).unwrap();
assert!(std::path::Path::new(&path).exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Is A: Theme"));
assert!(content.contains("# My Theme"));
assert!(content.contains("background:"));
}
#[test]
fn test_create_vault_theme_default_name() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, None).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# Untitled Theme"));
}
#[test]
fn test_create_vault_theme_avoids_conflicts() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let p1 = create_vault_theme(vp, Some("Custom")).unwrap();
let p2 = create_vault_theme(vp, Some("Custom")).unwrap();
assert_ne!(p1, p2);
}
#[test]
fn test_slugify() {
assert_eq!(slugify("My Cool Theme"), "my-cool-theme");
assert_eq!(slugify("default"), "default");
assert_eq!(slugify("Dark Mode!"), "dark-mode");
}
#[test]
fn test_create_vault_theme_contains_all_default_css_vars() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, Some("Full Theme")).unwrap();
let content = fs::read_to_string(&path).unwrap();
// Every entry in DEFAULT_VAULT_THEME_VARS must appear in the generated file
for (key, _) in &DEFAULT_VAULT_THEME_VARS {
assert!(
content.contains(&format!("{key}:")),
"missing key in theme file: {key}"
);
}
// Spot-check editor properties from theme.json that were previously missing
assert!(
content.contains("editor-font-family:"),
"missing editor-font-family"
);
assert!(
content.contains("editor-padding-horizontal:"),
"missing editor-padding-horizontal"
);
assert!(
content.contains("headings-h1-font-size:"),
"missing headings-h1-font-size"
);
assert!(
content.contains("lists-bullet-size:"),
"missing lists-bullet-size"
);
assert!(
content.contains("lists-bullet-color:"),
"missing lists-bullet-color"
);
assert!(
content.contains("checkboxes-size:"),
"missing checkboxes-size"
);
assert!(
content.contains("inline-styles-bold-font-weight:"),
"missing inline-styles-bold-font-weight"
);
assert!(
content.contains("code-blocks-font-family:"),
"missing code-blocks-font-family"
);
assert!(
content.contains("blockquote-border-left-width:"),
"missing blockquote-border-left-width"
);
assert!(
content.contains("table-border-color:"),
"missing table-border-color"
);
assert!(
content.contains("horizontal-rule-thickness:"),
"missing horizontal-rule-thickness"
);
assert!(content.contains("colors-text:"), "missing colors-text");
assert!(content.contains("colors-cursor:"), "missing colors-cursor");
// Numeric values that need CSS units must have px suffix
assert!(
content.contains("editor-font-size: 15px"),
"editor-font-size should have px unit"
);
assert!(
content.contains("editor-max-width: 720px"),
"editor-max-width should have px unit"
);
assert!(
content.contains("editor-padding-horizontal: 40px"),
"editor-padding-horizontal should have px unit"
);
}
}

View File

@@ -1,453 +0,0 @@
/// Content for the built-in default (light) theme.
pub const DEFAULT_THEME: &str = r##"{
"name": "Default",
"description": "Light theme with warm, paper-like tones",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"destructive": "#E03E3E",
"border": "#E9E9E7",
"input": "#E9E9E7",
"ring": "#155DFF",
"sidebar-background": "#F7F6F3",
"sidebar-foreground": "#37352F",
"sidebar-border": "#E9E9E7",
"sidebar-accent": "#EBEBEA"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}"##;
/// Content for the built-in dark theme.
pub const DARK_THEME: &str = r##"{
"name": "Dark",
"description": "Dark variant with deep navy tones",
"colors": {
"background": "#0f0f1a",
"foreground": "#e0e0e0",
"card": "#16162a",
"popover": "#1e1e3a",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#2a2a4a",
"secondary-foreground": "#e0e0e0",
"muted": "#1e1e3a",
"muted-foreground": "#888888",
"accent": "#2a2a4a",
"accent-foreground": "#e0e0e0",
"destructive": "#f44336",
"border": "#2a2a4a",
"input": "#2a2a4a",
"ring": "#155DFF",
"sidebar-background": "#1a1a2e",
"sidebar-foreground": "#e0e0e0",
"sidebar-border": "#2a2a4a",
"sidebar-accent": "#2a2a4a"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}"##;
/// Content for the built-in minimal theme.
pub const MINIMAL_THEME: &str = r##"{
"name": "Minimal",
"description": "High contrast, minimal chrome",
"colors": {
"background": "#FAFAFA",
"foreground": "#111111",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#000000",
"primary-foreground": "#FFFFFF",
"secondary": "#F0F0F0",
"secondary-foreground": "#111111",
"muted": "#F5F5F5",
"muted-foreground": "#666666",
"accent": "#F0F0F0",
"accent-foreground": "#111111",
"destructive": "#CC0000",
"border": "#E0E0E0",
"input": "#E0E0E0",
"ring": "#000000",
"sidebar-background": "#F5F5F5",
"sidebar-foreground": "#111111",
"sidebar-border": "#E0E0E0",
"sidebar-accent": "#E8E8E8"
},
"typography": {
"font-family": "'SF Mono', 'Menlo', monospace",
"font-size-base": "13px"
},
"spacing": {
"sidebar-width": "220px"
}
}"##;
// ---------------------------------------------------------------------------
// Vault-based theme notes (markdown with frontmatter CSS custom properties)
// ---------------------------------------------------------------------------
/// Complete set of CSS variable key-value pairs for the default light vault theme.
/// Includes both UI chrome colours and all editor styling properties from theme.json.
/// Numeric values that need CSS units include the `px` suffix; unitless values
/// (line-height, font-weight) are bare numbers.
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 140] = [
// ── shadcn/ui base colours ──────────────────────────────────────────
("background", "#FFFFFF"),
("foreground", "#37352F"),
("card", "#FFFFFF"),
("popover", "#FFFFFF"),
("primary", "#155DFF"),
("primary-foreground", "#FFFFFF"),
("secondary", "#EBEBEA"),
("secondary-foreground", "#37352F"),
("muted", "#F0F0EF"),
("muted-foreground", "#787774"),
("accent", "#EBEBEA"),
("accent-foreground", "#37352F"),
("destructive", "#E03E3E"),
("border", "#E9E9E7"),
("input", "#E9E9E7"),
("ring", "#155DFF"),
("sidebar", "#F7F6F3"),
("sidebar-foreground", "#37352F"),
("sidebar-border", "#E9E9E7"),
("sidebar-accent", "#EBEBEA"),
// ── Text hierarchy ──────────────────────────────────────────────────
("text-primary", "#37352F"),
("text-secondary", "#787774"),
("text-tertiary", "#B4B4B4"),
("text-muted", "#B4B4B4"),
("text-heading", "#37352F"),
// ── Backgrounds ─────────────────────────────────────────────────────
("bg-primary", "#FFFFFF"),
("bg-card", "#FFFFFF"),
("bg-sidebar", "#F7F6F3"),
("bg-hover", "#EBEBEA"),
("bg-hover-subtle", "#F0F0EF"),
("bg-selected", "#E8F4FE"),
("border-primary", "#E9E9E7"),
// ── Accent colours ──────────────────────────────────────────────────
("accent-blue", "#155DFF"),
("accent-green", "#00B38B"),
("accent-orange", "#D9730D"),
("accent-red", "#E03E3E"),
("accent-purple", "#A932FF"),
("accent-yellow", "#F0B100"),
("accent-blue-light", "#155DFF14"),
("accent-green-light", "#00B38B14"),
("accent-purple-light", "#A932FF14"),
("accent-red-light", "#E03E3E14"),
("accent-yellow-light", "#F0B10014"),
// ── Typography base ─────────────────────────────────────────────────
(
"font-family",
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
),
("font-size-base", "14px"),
// ── Editor (from theme.json → editor) ───────────────────────────────
(
"editor-font-family",
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
),
("editor-font-size", "15px"),
("editor-line-height", "1.5"),
("editor-max-width", "720px"),
("editor-padding-horizontal", "40px"),
("editor-padding-vertical", "20px"),
("editor-paragraph-spacing", "8px"),
// ── Headings H1 ────────────────────────────────────────────────────
("headings-h1-font-size", "32px"),
("headings-h1-font-weight", "700"),
("headings-h1-line-height", "1.2"),
("headings-h1-margin-top", "32px"),
("headings-h1-margin-bottom", "12px"),
("headings-h1-color", "var(--text-heading)"),
("headings-h1-letter-spacing", "-0.5px"),
// ── Headings H2 ────────────────────────────────────────────────────
("headings-h2-font-size", "27px"),
("headings-h2-font-weight", "600"),
("headings-h2-line-height", "1.4"),
("headings-h2-margin-top", "28px"),
("headings-h2-margin-bottom", "10px"),
("headings-h2-color", "var(--text-heading)"),
("headings-h2-letter-spacing", "-0.5px"),
// ── Headings H3 ────────────────────────────────────────────────────
("headings-h3-font-size", "20px"),
("headings-h3-font-weight", "600"),
("headings-h3-line-height", "1.4"),
("headings-h3-margin-top", "24px"),
("headings-h3-margin-bottom", "8px"),
("headings-h3-color", "var(--text-heading)"),
("headings-h3-letter-spacing", "-0.5px"),
// ── Headings H4 ────────────────────────────────────────────────────
("headings-h4-font-size", "20px"),
("headings-h4-font-weight", "600"),
("headings-h4-line-height", "1.4"),
("headings-h4-margin-top", "20px"),
("headings-h4-margin-bottom", "6px"),
("headings-h4-color", "var(--text-heading)"),
("headings-h4-letter-spacing", "0px"),
// ── Lists ───────────────────────────────────────────────────────────
("lists-bullet-size", "28px"),
("lists-bullet-color", "#177bfd"),
("lists-indent-size", "24px"),
("lists-item-spacing", "4px"),
("lists-padding-left", "8px"),
("lists-bullet-gap", "6px"),
// ── Checkboxes ──────────────────────────────────────────────────────
("checkboxes-size", "18px"),
("checkboxes-border-radius", "3px"),
("checkboxes-checked-color", "var(--accent-blue)"),
("checkboxes-unchecked-border-color", "var(--text-muted)"),
("checkboxes-gap", "8px"),
// ── Inline styles: bold ─────────────────────────────────────────────
("inline-styles-bold-font-weight", "700"),
("inline-styles-bold-color", "var(--text-primary)"),
// ── Inline styles: italic ───────────────────────────────────────────
("inline-styles-italic-font-style", "italic"),
("inline-styles-italic-color", "var(--text-primary)"),
// ── Inline styles: strikethrough ────────────────────────────────────
("inline-styles-strikethrough-color", "var(--text-tertiary)"),
(
"inline-styles-strikethrough-text-decoration",
"line-through",
),
// ── Inline styles: code ─────────────────────────────────────────────
(
"inline-styles-code-font-family",
"'SF Mono', 'Fira Code', monospace",
),
("inline-styles-code-font-size", "14px"),
(
"inline-styles-code-background-color",
"var(--bg-hover-subtle)",
),
("inline-styles-code-padding-horizontal", "4px"),
("inline-styles-code-padding-vertical", "2px"),
("inline-styles-code-border-radius", "3px"),
("inline-styles-code-color", "var(--text-secondary)"),
// ── Inline styles: link ─────────────────────────────────────────────
("inline-styles-link-color", "var(--accent-blue)"),
("inline-styles-link-text-decoration", "underline"),
// ── Inline styles: wikilink ─────────────────────────────────────────
("inline-styles-wikilink-color", "var(--accent-blue)"),
("inline-styles-wikilink-text-decoration", "none"),
(
"inline-styles-wikilink-border-bottom",
"1px dotted currentColor",
),
("inline-styles-wikilink-cursor", "pointer"),
// ── Code blocks ─────────────────────────────────────────────────────
(
"code-blocks-font-family",
"'SF Mono', 'Fira Code', monospace",
),
("code-blocks-font-size", "13px"),
("code-blocks-line-height", "1.5"),
("code-blocks-background-color", "var(--bg-card)"),
("code-blocks-padding-horizontal", "16px"),
("code-blocks-padding-vertical", "12px"),
("code-blocks-border-radius", "6px"),
("code-blocks-margin-vertical", "12px"),
// ── Blockquote ──────────────────────────────────────────────────────
("blockquote-border-left-width", "3px"),
("blockquote-border-left-color", "var(--accent-blue)"),
("blockquote-padding-left", "16px"),
("blockquote-margin-vertical", "12px"),
("blockquote-color", "var(--text-secondary)"),
("blockquote-font-style", "italic"),
// ── Table ───────────────────────────────────────────────────────────
("table-border-color", "var(--border-primary)"),
("table-header-background", "var(--bg-card)"),
("table-cell-padding-horizontal", "12px"),
("table-cell-padding-vertical", "8px"),
("table-font-size", "14px"),
// ── Horizontal rule ─────────────────────────────────────────────────
("horizontal-rule-color", "var(--border-primary)"),
("horizontal-rule-margin-vertical", "24px"),
("horizontal-rule-thickness", "1px"),
// ── Colors (semantic aliases from theme.json → colors) ──────────────
("colors-background", "var(--bg-primary)"),
("colors-text", "var(--text-primary)"),
("colors-text-secondary", "var(--text-secondary)"),
("colors-text-muted", "var(--text-muted)"),
("colors-heading", "var(--text-heading)"),
("colors-accent", "var(--accent-blue)"),
("colors-selection", "var(--bg-selected)"),
("colors-cursor", "var(--text-primary)"),
];
/// UI-colour overrides for the Dark vault theme (keys that differ from default).
const DARK_COLOR_OVERRIDES: &[(&str, &str)] = &[
("background", "#0f0f1a"),
("foreground", "#e0e0e0"),
("card", "#16162a"),
("popover", "#1e1e3a"),
("secondary", "#2a2a4a"),
("secondary-foreground", "#e0e0e0"),
("muted", "#1e1e3a"),
("muted-foreground", "#888888"),
("accent", "#2a2a4a"),
("accent-foreground", "#e0e0e0"),
("destructive", "#f44336"),
("border", "#2a2a4a"),
("input", "#2a2a4a"),
("sidebar", "#1a1a2e"),
("sidebar-foreground", "#e0e0e0"),
("sidebar-border", "#2a2a4a"),
("sidebar-accent", "#2a2a4a"),
("text-primary", "#e0e0e0"),
("text-secondary", "#888888"),
("text-tertiary", "#666666"),
("text-muted", "#666666"),
("text-heading", "#e0e0e0"),
("bg-primary", "#0f0f1a"),
("bg-card", "#16162a"),
("bg-sidebar", "#1a1a2e"),
("bg-hover", "#2a2a4a"),
("bg-hover-subtle", "#1e1e3a"),
("bg-selected", "#155DFF22"),
("border-primary", "#2a2a4a"),
("accent-red", "#f44336"),
("accent-blue-light", "#155DFF33"),
("accent-green-light", "#00B38B33"),
("accent-purple-light", "#A932FF33"),
("accent-red-light", "#f4433633"),
("accent-yellow-light", "#F0B10033"),
("lists-bullet-color", "#155DFF"),
];
/// UI-colour + editor-property overrides for the Minimal vault theme.
const MINIMAL_OVERRIDES: &[(&str, &str)] = &[
("background", "#FAFAFA"),
("foreground", "#111111"),
("primary", "#000000"),
("secondary", "#F0F0F0"),
("secondary-foreground", "#111111"),
("muted", "#F5F5F5"),
("muted-foreground", "#666666"),
("accent", "#F0F0F0"),
("accent-foreground", "#111111"),
("destructive", "#CC0000"),
("border", "#E0E0E0"),
("input", "#E0E0E0"),
("ring", "#000000"),
("sidebar", "#F5F5F5"),
("sidebar-foreground", "#111111"),
("sidebar-border", "#E0E0E0"),
("sidebar-accent", "#E8E8E8"),
("text-primary", "#111111"),
("text-secondary", "#666666"),
("text-tertiary", "#999999"),
("text-muted", "#999999"),
("text-heading", "#111111"),
("bg-primary", "#FAFAFA"),
("bg-card", "#FFFFFF"),
("bg-sidebar", "#F5F5F5"),
("bg-hover", "#EBEBEB"),
("bg-hover-subtle", "#F5F5F5"),
("bg-selected", "#00000014"),
("border-primary", "#E0E0E0"),
("accent-blue", "#000000"),
("accent-green", "#006600"),
("accent-orange", "#996600"),
("accent-red", "#CC0000"),
("accent-purple", "#660099"),
("accent-yellow", "#996600"),
("accent-blue-light", "#00000014"),
("accent-green-light", "#00660014"),
("accent-purple-light", "#66009914"),
("accent-red-light", "#CC000014"),
("accent-yellow-light", "#99660014"),
("font-family", "'SF Mono', 'Menlo', monospace"),
("font-size-base", "13px"),
("editor-font-size", "15px"),
("editor-line-height", "1.6"),
("editor-max-width", "680px"),
("lists-bullet-color", "#000000"),
];
/// Build a vault theme note string from a set of CSS variable pairs.
///
/// Values containing `#`, `'`, `,`, or `(` are YAML-quoted to avoid parse errors.
fn build_vault_theme_note(name: &str, description: &str, vars: &[(&str, &str)]) -> String {
let mut fm = format!("---\ntype: Theme\nDescription: {description}\n");
for (key, value) in vars {
if value.contains('#') || value.contains('\'') || value.contains(',') || value.contains('(')
{
fm.push_str(&format!("{key}: \"{value}\"\n"));
} else {
fm.push_str(&format!("{key}: {value}\n"));
}
}
fm.push_str("---\n\n");
fm.push_str(&format!("# {name} Theme\n\n{description}.\n"));
fm
}
/// Apply overrides on top of DEFAULT_VAULT_THEME_VARS, returning a new Vec.
fn apply_overrides(
overrides: &[(&'static str, &'static str)],
) -> Vec<(&'static str, &'static str)> {
let mut vars: Vec<(&'static str, &'static str)> = DEFAULT_VAULT_THEME_VARS.to_vec();
for &(key, value) in overrides {
if let Some(entry) = vars.iter_mut().find(|e| e.0 == key) {
entry.1 = value;
}
}
vars
}
/// Generate the Default vault theme note content.
pub fn default_vault_theme() -> String {
build_vault_theme_note(
"Default",
"Light theme with warm, paper-like tones",
&DEFAULT_VAULT_THEME_VARS,
)
}
/// Generate the Dark vault theme note content.
pub fn dark_vault_theme() -> String {
let vars = apply_overrides(DARK_COLOR_OVERRIDES);
build_vault_theme_note("Dark", "Dark variant with deep navy tones", &vars)
}
/// Generate the Minimal vault theme note content.
pub fn minimal_vault_theme() -> String {
let vars = apply_overrides(MINIMAL_OVERRIDES);
build_vault_theme_note("Minimal", "High contrast, minimal chrome", &vars)
}
/// Type definition for the Theme note type.
pub const THEME_TYPE_DEFINITION: &str = "---\n\
type: Type\n\
icon: palette\n\
color: purple\n\
order: 50\n\
---\n\
\n\
# Theme\n\
\n\
A visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n";

View File

@@ -1,263 +0,0 @@
mod create;
pub mod defaults;
mod seed;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
pub use create::{create_theme, create_vault_theme};
pub use defaults::*;
pub use seed::{
ensure_theme_type_definition, ensure_vault_themes, migrate_legacy_themes_dir,
migrate_theme_dir_to_root, restore_default_themes, seed_vault_themes,
};
/// A theme file parsed from _themes/*.json in the vault.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeFile {
/// Filename stem (e.g. "default" for _themes/default.json)
#[serde(default)]
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub colors: HashMap<String, String>,
#[serde(default)]
pub typography: HashMap<String, String>,
#[serde(default)]
pub spacing: HashMap<String, String>,
}
/// Vault-level settings stored in .laputa/settings.json (git-tracked).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VaultSettings {
#[serde(default)]
pub theme: Option<String>,
}
/// List all theme files in _themes/ directory of the vault (legacy).
/// Returns an empty list if the directory doesn't exist.
pub fn list_themes(vault_path: &str) -> Result<Vec<ThemeFile>, String> {
let themes_dir = Path::new(vault_path).join("_themes");
if !themes_dir.is_dir() {
return Ok(Vec::new());
}
let mut themes = Vec::new();
let entries =
fs::read_dir(&themes_dir).map_err(|e| format!("Failed to read _themes directory: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match parse_theme_file(&path) {
Ok(theme) => themes.push(theme),
Err(e) => log::warn!("Skipping theme file {}: {e}", path.display()),
}
}
themes.sort_by(|a, b| a.name.cmp(&b.name));
Ok(themes)
}
/// Parse a single theme JSON file.
fn parse_theme_file(path: &Path) -> Result<ThemeFile, String> {
let id = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.ok_or_else(|| "Invalid theme filename".to_string())?;
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
let mut theme: ThemeFile = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
theme.id = id;
Ok(theme)
}
/// Read vault-level settings from .laputa/settings.json.
pub fn get_vault_settings(vault_path: &str) -> Result<VaultSettings, String> {
let settings_path = Path::new(vault_path).join(".laputa").join("settings.json");
if !settings_path.exists() {
return Ok(VaultSettings::default());
}
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read vault settings: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault settings: {e}"))
}
/// Save vault-level settings to .laputa/settings.json.
pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<(), String> {
let laputa_dir = Path::new(vault_path).join(".laputa");
fs::create_dir_all(&laputa_dir)
.map_err(|e| format!("Failed to create .laputa directory: {e}"))?;
let json = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize vault settings: {e}"))?;
fs::write(laputa_dir.join("settings.json"), json)
.map_err(|e| format!("Failed to write vault settings: {e}"))
}
/// Set the active theme in vault settings. Pass `None` to clear.
pub fn set_active_theme(vault_path: &str, theme_id: Option<&str>) -> Result<(), String> {
let mut settings = get_vault_settings(vault_path)?;
settings.theme = theme_id.map(|s| s.to_string());
save_vault_settings(vault_path, settings)
}
/// Read a single theme file by ID from the vault's _themes/ directory.
pub fn get_theme(vault_path: &str, theme_id: &str) -> Result<ThemeFile, String> {
let path = Path::new(vault_path)
.join("_themes")
.join(format!("{theme_id}.json"));
if !path.exists() {
return Err(format!("Theme not found: {theme_id}"));
}
parse_theme_file(&path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_vault_with_themes(dir: &TempDir) -> String {
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
vault.to_string_lossy().to_string()
}
#[test]
fn test_list_themes_returns_sorted_list() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
assert_eq!(themes[0].id, "dark");
assert_eq!(themes[1].id, "default");
}
#[test]
fn test_list_themes_returns_empty_when_no_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("empty-vault");
fs::create_dir_all(&vault).unwrap();
let themes = list_themes(vault.to_str().unwrap()).unwrap();
assert!(themes.is_empty(), "must return empty when _themes/ absent");
assert!(!vault.join("_themes").exists(), "must not create _themes/");
}
#[test]
fn test_get_theme_by_id() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let theme = get_theme(&vault, "default").unwrap();
assert_eq!(theme.name, "Default");
assert!(!theme.colors.is_empty());
}
#[test]
fn test_get_theme_not_found() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let result = get_theme(&vault, "nonexistent");
assert!(result.is_err());
}
#[test]
fn test_vault_settings_roundtrip() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let settings = get_vault_settings(vp).unwrap();
assert!(settings.theme.is_none());
set_active_theme(vp, Some("dark")).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme.as_deref(), Some("dark"));
set_active_theme(vp, None).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme, None);
}
#[test]
fn test_vault_settings_creates_laputa_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
assert!(!vault.join(".laputa").exists());
save_vault_settings(
vp,
VaultSettings {
theme: Some("light".into()),
},
)
.unwrap();
assert!(vault.join(".laputa").join("settings.json").exists());
}
#[test]
fn test_parse_all_builtin_themes() {
for (name, content) in [
("default", DEFAULT_THEME),
("dark", DARK_THEME),
("minimal", MINIMAL_THEME),
] {
let theme: ThemeFile = serde_json::from_str(content)
.unwrap_or_else(|e| panic!("Failed to parse {name} theme: {e}"));
assert!(!theme.name.is_empty(), "{name} theme should have a name");
assert!(!theme.colors.is_empty(), "{name} theme should have colors");
}
}
#[test]
fn test_list_themes_ignores_non_json_files() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes_dir = Path::new(&vault).join("_themes");
fs::write(themes_dir.join("readme.txt"), "not a theme").unwrap();
fs::write(themes_dir.join(".DS_Store"), "").unwrap();
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
}
#[test]
fn test_list_themes_skips_malformed_json() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes_dir = Path::new(&vault).join("_themes");
fs::write(themes_dir.join("broken.json"), "not valid json{{{").unwrap();
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
}
#[test]
fn test_vault_theme_content_contains_all_vars() {
let content = default_vault_theme();
assert!(content.contains("background:"));
assert!(content.contains("primary:"));
assert!(content.contains("sidebar:"));
assert!(content.contains("text-primary:"));
assert!(content.contains("accent-blue:"));
assert!(content.contains("editor-font-size:"));
}
}

View File

@@ -1,554 +0,0 @@
use std::fs;
use std::path::Path;
use super::defaults::*;
/// Write a vault theme file if it doesn't exist or is empty (corrupt).
fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
}
Ok(needs_write)
}
/// Filenames for built-in vault theme notes at vault root (flat structure).
const VAULT_THEME_FILES: [&str; 3] = ["default-theme.md", "dark-theme.md", "minimal-theme.md"];
/// Seed built-in vault theme notes at vault root (flat structure).
/// Per-file idempotent: writes each default file only when it doesn't exist
/// or is empty (corrupt). Never overwrites existing files that have content.
pub fn seed_vault_themes(vault_path: &str) {
let vault = Path::new(vault_path);
let default_content = default_vault_theme();
let dark_content = dark_vault_theme();
let minimal_content = minimal_vault_theme();
let defaults: &[(&str, &str)] = &[
(VAULT_THEME_FILES[0], &default_content),
(VAULT_THEME_FILES[1], &dark_content),
(VAULT_THEME_FILES[2], &minimal_content),
];
let mut seeded = false;
for (name, content) in defaults {
let wrote = write_if_missing(&vault.join(name), content).unwrap_or(false);
seeded = seeded || wrote;
}
if seeded {
log::info!("Seeded vault root with built-in vault themes");
}
}
/// Ensure vault theme files exist at vault root (flat structure).
/// Returns an error on read-only filesystem.
pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let default_content = default_vault_theme();
let dark_content = dark_vault_theme();
let minimal_content = minimal_vault_theme();
let defaults: &[(&str, &str)] = &[
(VAULT_THEME_FILES[0], &default_content),
(VAULT_THEME_FILES[1], &dark_content),
(VAULT_THEME_FILES[2], &minimal_content),
];
for (name, content) in defaults {
write_if_missing(&vault.join(name), content)
.map_err(|e| format!("Failed to write {name}: {e}"))?;
}
Ok(())
}
/// Restore default themes for a vault: seeds vault root theme notes (flat
/// structure) and the theme.md type definition. Per-file idempotent — never
/// overwrites files that already have content. Returns an error on read-only
/// filesystems.
pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
// Seed vault theme notes at root (flat structure)
ensure_vault_themes(vault_path)?;
// Seed theme.md type definition so the Theme type has an icon and label in the sidebar
ensure_theme_type_definition(vault_path)?;
Ok("Default themes restored".to_string())
}
/// Create `theme.md` at vault root if it doesn't exist (gives the Theme type a sidebar icon/color).
pub fn ensure_theme_type_definition(vault_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
write_if_missing(&vault.join("theme.md"), THEME_TYPE_DEFINITION)?;
Ok(())
}
/// Migrate legacy `theme/` directory vault notes to root (flat structure).
///
/// Moves `theme/default.md` → `default-theme.md`, etc. Only moves a file if the
/// target doesn't exist yet (preserves existing root files). Cleans up the empty
/// `theme/` directory afterwards. Idempotent and silent.
pub fn migrate_theme_dir_to_root(vault_path: &str) {
let vault = Path::new(vault_path);
let theme_dir = vault.join("theme");
if !theme_dir.is_dir() {
return;
}
let migrations: &[(&str, &str)] = &[
("default.md", "default-theme.md"),
("dark.md", "dark-theme.md"),
("minimal.md", "minimal-theme.md"),
];
for (old_name, new_name) in migrations {
let old_path = theme_dir.join(old_name);
let new_path = vault.join(new_name);
if old_path.exists() && !new_path.exists() {
if let Ok(content) = fs::read_to_string(&old_path) {
if !content.is_empty() {
let _ = fs::write(&new_path, &content);
log::info!("Migrated theme/{old_name} → {new_name}");
}
}
let _ = fs::remove_file(&old_path);
} else if old_path.exists() {
// Target exists, just remove the old file
let _ = fs::remove_file(&old_path);
}
}
// Clean up empty theme/ directory
if theme_dir.is_dir() {
let is_empty = fs::read_dir(&theme_dir).map_or(true, |mut d| d.next().is_none());
if is_empty {
let _ = fs::remove_dir(&theme_dir);
log::info!("Removed empty theme/ directory");
}
}
}
/// Remove the legacy `_themes/` directory if it only contains default JSON files.
/// Leaves the directory intact if it has any custom (non-default) files.
/// Idempotent and silent.
pub fn migrate_legacy_themes_dir(vault_path: &str) {
let themes_dir = Path::new(vault_path).join("_themes");
if !themes_dir.is_dir() {
return;
}
let default_filenames: &[&str] = &["default.json", "dark.json", "minimal.json"];
// Check if directory only has default files (or is empty)
let has_custom = fs::read_dir(&themes_dir).is_ok_and(|entries| {
entries.filter_map(|e| e.ok()).any(|e| {
let name = e.file_name();
let name_str = name.to_string_lossy();
!default_filenames.contains(&name_str.as_ref())
})
});
if has_custom {
return;
}
// Remove default JSON files then the empty directory
for name in default_filenames {
let _ = fs::remove_file(themes_dir.join(name));
}
let _ = fs::remove_dir(&themes_dir);
log::info!("Removed legacy _themes/ directory");
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_seed_vault_themes_creates_files_at_root() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
assert!(vault.join("default-theme.md").exists());
assert!(vault.join("dark-theme.md").exists());
assert!(vault.join("minimal-theme.md").exists());
// Must NOT create a theme/ subdirectory
assert!(!vault.join("theme").exists());
}
#[test]
fn test_seed_vault_themes_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
seed_vault_themes(vp); // second call should be a no-op
assert!(vault.join("default-theme.md").exists());
}
#[test]
fn test_seed_vault_themes_writes_missing_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("default-theme.md"), &default_vault_theme()).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
assert!(vault.join("dark-theme.md").exists());
assert!(vault.join("minimal-theme.md").exists());
}
#[test]
fn test_seed_vault_themes_reseeds_empty_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("default-theme.md"), "").unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
let content = fs::read_to_string(vault.join("default-theme.md")).unwrap();
assert!(content.contains("type: Theme"));
}
#[test]
fn test_seed_vault_themes_preserves_existing_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let custom = "---\ntype: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
fs::write(vault.join("default-theme.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
let content = fs::read_to_string(vault.join("default-theme.md")).unwrap();
assert!(
content.contains("#FF0000"),
"existing content must be preserved"
);
}
#[test]
fn test_ensure_vault_themes_creates_root_level_defaults() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
assert!(vault.join("default-theme.md").exists());
assert!(vault.join("dark-theme.md").exists());
assert!(vault.join("minimal-theme.md").exists());
// Must NOT create a theme/ subdirectory
assert!(!vault.join("theme").exists());
}
#[test]
fn test_ensure_vault_themes_reseeds_empty_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("default-theme.md"), "").unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(vault.join("default-theme.md")).unwrap();
assert!(content.contains("type: Theme"));
}
#[test]
fn test_ensure_vault_themes_preserves_custom_themes() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let custom = "---\ntype: Theme\nbackground: \"#123456\"\n---\n";
fs::write(vault.join("default-theme.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(vault.join("default-theme.md")).unwrap();
assert!(content.contains("#123456"));
}
#[test]
fn test_restore_default_themes_creates_flat_structure() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let msg = restore_default_themes(vp).unwrap();
assert_eq!(msg, "Default themes restored");
// Must NOT create _themes/ directory (legacy)
assert!(!vault.join("_themes").exists());
// Vault theme notes at root (flat structure)
assert!(vault.join("default-theme.md").exists());
assert!(vault.join("dark-theme.md").exists());
assert!(vault.join("minimal-theme.md").exists());
// Must NOT create a theme/ subdirectory
assert!(!vault.join("theme").is_dir());
// Type definition at root
assert!(
vault.join("theme.md").exists(),
"restore must create theme.md"
);
let type_content = fs::read_to_string(vault.join("theme.md")).unwrap();
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("icon: palette"));
}
#[test]
fn test_ensure_theme_type_definition_creates_file() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
ensure_theme_type_definition(vp).unwrap();
let path = vault.join("theme.md");
assert!(path.exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("type: Type"));
assert!(content.contains("icon: palette"));
}
#[test]
fn test_ensure_theme_type_definition_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let custom = "---\ntype: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
fs::write(vault.join("theme.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
ensure_theme_type_definition(vp).unwrap();
let content = fs::read_to_string(vault.join("theme.md")).unwrap();
assert!(
content.contains("swatches"),
"existing content must be preserved"
);
}
#[test]
fn test_restore_default_themes_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
restore_default_themes(vp).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#CUSTOM\"\n---\n";
fs::write(vault.join("default-theme.md"), custom).unwrap();
restore_default_themes(vp).unwrap();
let content = fs::read_to_string(vault.join("default-theme.md")).unwrap();
assert!(
content.contains("#CUSTOM"),
"must not overwrite existing content"
);
}
#[test]
fn test_restore_default_themes_fills_partial_state() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("default-theme.md"), &default_vault_theme()).unwrap();
let vp = vault.to_str().unwrap();
restore_default_themes(vp).unwrap();
// Must NOT create _themes/ directory
assert!(!vault.join("_themes").exists());
assert!(vault.join("dark-theme.md").exists());
assert!(vault.join("minimal-theme.md").exists());
let content = fs::read_to_string(vault.join("default-theme.md")).unwrap();
assert!(content.contains("Light theme with warm"));
}
#[test]
fn test_seeded_default_theme_contains_editor_properties() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(vault.join("default-theme.md")).unwrap();
// Must contain all editor properties from theme.json
assert!(
content.contains("editor-font-family:"),
"missing editor-font-family"
);
assert!(
content.contains("headings-h1-font-size:"),
"missing headings-h1-font-size"
);
assert!(
content.contains("lists-bullet-size:"),
"missing lists-bullet-size"
);
assert!(
content.contains("checkboxes-size:"),
"missing checkboxes-size"
);
assert!(
content.contains("inline-styles-bold-font-weight:"),
"missing inline-styles-bold"
);
assert!(
content.contains("code-blocks-font-family:"),
"missing code-blocks-font-family"
);
assert!(
content.contains("blockquote-border-left-width:"),
"missing blockquote"
);
assert!(
content.contains("table-border-color:"),
"missing table-border-color"
);
assert!(
content.contains("horizontal-rule-thickness:"),
"missing horizontal-rule"
);
assert!(content.contains("colors-text:"), "missing colors-text");
}
#[test]
fn test_migrate_theme_dir_moves_files_to_root() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("default.md"), &default_vault_theme()).unwrap();
fs::write(theme_dir.join("dark.md"), &dark_vault_theme()).unwrap();
fs::write(theme_dir.join("minimal.md"), &minimal_vault_theme()).unwrap();
let vp = vault.to_str().unwrap();
migrate_theme_dir_to_root(vp);
assert!(vault.join("default-theme.md").exists());
assert!(vault.join("dark-theme.md").exists());
assert!(vault.join("minimal-theme.md").exists());
// Old files removed
assert!(!theme_dir.join("default.md").exists());
// Empty directory cleaned up
assert!(!theme_dir.exists());
}
#[test]
fn test_migrate_theme_dir_preserves_existing_root_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\ntype: Theme\nbackground: \"#CUSTOM\"\n---\n# Custom\n";
fs::write(vault.join("default-theme.md"), custom).unwrap();
fs::write(theme_dir.join("default.md"), &default_vault_theme()).unwrap();
let vp = vault.to_str().unwrap();
migrate_theme_dir_to_root(vp);
let content = fs::read_to_string(vault.join("default-theme.md")).unwrap();
assert!(
content.contains("#CUSTOM"),
"must preserve existing root file"
);
}
#[test]
fn test_migrate_theme_dir_noop_when_no_theme_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
migrate_theme_dir_to_root(vp);
assert!(!vault.join("theme").exists());
}
#[test]
fn test_migrate_theme_dir_keeps_nonempty_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("default.md"), &default_vault_theme()).unwrap();
fs::write(theme_dir.join("custom-theme.md"), "custom content").unwrap();
let vp = vault.to_str().unwrap();
migrate_theme_dir_to_root(vp);
assert!(vault.join("default-theme.md").exists());
assert!(theme_dir.join("custom-theme.md").exists());
assert!(theme_dir.exists());
}
#[test]
fn test_migrate_legacy_themes_dir_removes_defaults_only() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
fs::write(themes_dir.join("minimal.json"), MINIMAL_THEME).unwrap();
let vp = vault.to_str().unwrap();
migrate_legacy_themes_dir(vp);
assert!(
!themes_dir.exists(),
"_themes/ must be removed when only defaults"
);
}
#[test]
fn test_migrate_legacy_themes_dir_keeps_custom_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(themes_dir.join("custom.json"), r#"{"name":"Custom"}"#).unwrap();
let vp = vault.to_str().unwrap();
migrate_legacy_themes_dir(vp);
assert!(
themes_dir.exists(),
"_themes/ must be kept when custom files present"
);
assert!(themes_dir.join("default.json").exists());
assert!(themes_dir.join("custom.json").exists());
}
#[test]
fn test_migrate_legacy_themes_dir_noop_when_absent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
migrate_legacy_themes_dir(vp);
assert!(!vault.join("_themes").exists());
}
#[test]
fn test_migrate_legacy_themes_dir_removes_empty() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
let vp = vault.to_str().unwrap();
migrate_legacy_themes_dir(vp);
assert!(!themes_dir.exists(), "empty _themes/ must be removed");
}
}

View File

@@ -402,23 +402,6 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
}
// Seed vault theme notes at root (flat structure)
fs::write(
vault_dir.join("default-theme.md"),
crate::theme::default_vault_theme(),
)
.map_err(|e| format!("Failed to write default vault theme: {e}"))?;
fs::write(
vault_dir.join("dark-theme.md"),
crate::theme::dark_vault_theme(),
)
.map_err(|e| format!("Failed to write dark vault theme: {e}"))?;
fs::write(
vault_dir.join("minimal-theme.md"),
crate::theme::minimal_vault_theme(),
)
.map_err(|e| format!("Failed to write minimal vault theme: {e}"))?;
crate::git::init_repo(target_path)?;
Ok(vault_dir
@@ -521,8 +504,8 @@ mod tests {
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let entries = crate::vault::scan_vault(&vault_path).unwrap();
// SAMPLE_FILES + AGENTS.md + 3 vault theme notes (all at root)
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3);
// SAMPLE_FILES + AGENTS.md
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1);
}
#[test]
@@ -578,26 +561,6 @@ mod tests {
assert!(log_str.contains("Initial vault setup"));
}
#[test]
fn test_create_getting_started_vault_seeds_themes() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("theme-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
// Must NOT create legacy _themes/ directory
assert!(!vault_path.join("_themes").exists());
// Vault-based theme notes at root (flat structure)
assert!(vault_path.join("default-theme.md").exists());
assert!(vault_path.join("dark-theme.md").exists());
assert!(vault_path.join("minimal-theme.md").exists());
// Must NOT create a theme/ subdirectory
assert!(!vault_path.join("theme").exists());
// Theme type definition
assert!(vault_path.join("theme.md").exists());
}
#[test]
fn test_create_getting_started_vault_no_untracked_files() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -486,6 +486,79 @@ References:
);
}
// --- large relationship array (regression: No Code note with 32 Notes) ---
#[test]
fn test_parse_large_notes_relationship_array() {
let dir = TempDir::new().unwrap();
let content = r#"---
type: Topic
Referred by Data:
- "[[michele-sampieri|Michele Sampieri]]"
- "[[varun-anand|Varun Anand]]"
Belongs to:
- "[[engineering|Engineering]]"
aliases:
- No Code
Notes:
- "[[8020-we-help-companies-move-faster-without-code|8020 | We help companies move faster without code.]]"
- "[[airdev-build-hub|Airdev Build Hub]]"
- "[[airdev-leader-in-bubble-and-no-code-development|AirDev | Leader in Bubble and No-Code Development]]"
- "[[budibase-internal-tools-made-easy|Budibase - Internal tools made easy]]"
- "[[bullet-launch-bubble-boilerplate|Bullet Launch Bubble Boilerplate]]"
- "[[canvas-base-template-for-bubble|Canvas • Base Template for Bubble]]"
- "[[chameleon-microsurveys|Chameleon | Microsurveys]]"
- "[[felt-the-best-way-to-make-maps-on-the-internet|Felt The best way to make maps on the internet]]"
- "[[flutterflow-build-native-apps-visually|FlutterFlow | Build Native Apps Visually]]"
- "[[framer-ai-generate-and-publish-your-site-with-ai-in-seconds|Framer AI — Generate and publish your site with AI in seconds.]]"
- "[[jumpstart-pro-the-best-ruby-on-rails-saas-template|Jumpstart Pro | The best Ruby on Rails SaaS Template]]"
- "[[mailparser-email-parser-software-workflow-automation|MailParser • Email Parser Software & Workflow Automation]]"
- "[[make-work-the-way-you-imagine|Make | Work the way you imagine]]"
- "[[michele-sampieri|Michele Sampieri]]"
- "[[n8nio-a-powerful-workflow-automation-tool|n8n.io - a powerful workflow automation tool]]"
- "[[n8nio-ai-workflow-automation-tool|n8n.io - AI workflow automation tool]]"
- "[[nocodey-find-best-nocoder|Nocodey • Find Best Nocoder]]"
- "[[outseta-software-for-subscription-start-ups|Outseta | Software for subscription start-ups]]"
- "[[payments-tax-subscriptions-for-software-companies-lemon-squeezy|Payments, tax & subscriptions for software companies • Lemon Squeezy]]"
- "[[retool-portals-custom-client-portal-software|Retool Portals • Custom Client Portal Software]]"
- "[[rise-of-the-no-code-economy-report-formstack|Rise of the No-Code Economy Report | Formstack]]"
- "[[scene-the-smart-way-to-build-websites|Scene • The smart way to build websites]]"
- "[[scrapingbee-the-best-web-scraping-api|ScrapingBee • the best web scraping API]]"
- "[[softr-build-a-website-web-app-or-portal-on-airtable-without-code|Softr | Build a website, web app or portal on Airtable without code]]"
- "[[superblocks-build-modern-internal-apps-in-days-not-months|Superblocks • Build modern internal apps in days, not months]]"
- "[[superwall-quickly-deploy-paywalls|Superwall • Quickly deploy paywalls]]"
- "[[tails-tailwind-css-page-creator|Tails | Tailwind CSS Page Creator]]"
- "[[the-open-source-firebase-alternative-supabase|The Open Source Firebase Alternative | Supabase]]"
- "[[varun-anand|Varun Anand]]"
- "[[xano-the-fastest-no-code-backend-development-platform|Xano - The Fastest No Code Backend Development Platform]]"
- "[[directus-open-data-platform-for-headless-content-management|{'Directus': 'Open Data Platform for Headless Content Management'}]]"
- "[[framer-design-beautiful-websites-in-minutes|{'Framer': 'Design beautiful websites in minutes'}]]"
title: No Code
---
# No Code
"#;
create_test_file(dir.path(), "no-code.md", content);
let entry = parse_md_file(&dir.path().join("no-code.md")).unwrap();
let notes = entry
.relationships
.get("Notes")
.expect("Notes relationship should exist");
assert_eq!(notes.len(), 32, "All 32 Notes entries should be parsed");
let referred = entry
.relationships
.get("Referred by Data")
.expect("Referred by Data should exist");
assert_eq!(referred.len(), 2);
let belongs = entry
.relationships
.get("Belongs to")
.expect("Belongs to should exist");
assert_eq!(belongs.len(), 1);
}
// --- type from frontmatter only (no folder inference) ---
#[test]

View File

@@ -1,481 +0,0 @@
use gray_matter::engine::YAML;
use gray_matter::Matter;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// Vault-wide UI configuration stored in `config/ui.config.md`.
///
/// This file is a regular vault note with YAML frontmatter, visible in the
/// sidebar under the "Config" section and editable like any note.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct VaultConfig {
pub zoom: Option<f64>,
pub view_mode: Option<String>,
pub editor_mode: Option<String>,
#[serde(default)]
pub tag_colors: Option<HashMap<String, String>>,
#[serde(default)]
pub status_colors: Option<HashMap<String, String>>,
#[serde(default)]
pub property_display_modes: Option<HashMap<String, String>>,
}
const CONFIG_DIR: &str = "config";
const CONFIG_FILENAME: &str = "ui.config.md";
fn config_path(vault_path: &str) -> std::path::PathBuf {
Path::new(vault_path).join(CONFIG_DIR).join(CONFIG_FILENAME)
}
/// Read the vault-wide UI config from `config/ui.config.md`.
/// Returns default values if the file doesn't exist.
pub fn get_vault_config(vault_path: &str) -> Result<VaultConfig, String> {
let path = config_path(vault_path);
if !path.exists() {
return Ok(VaultConfig::default());
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
parse_vault_config(&content)
}
/// Parse VaultConfig from markdown content with YAML frontmatter.
fn parse_vault_config(content: &str) -> Result<VaultConfig, String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let hash = match parsed.data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return Ok(VaultConfig::default()),
};
let json_map: serde_json::Map<String, serde_json::Value> =
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
let json = serde_json::Value::Object(json_map);
serde_json::from_value(json.clone())
.or_else(|_| {
// If direct deserialization fails, strip the `type` field and retry
let mut map = match json {
serde_json::Value::Object(m) => m,
_ => return Ok(VaultConfig::default()),
};
map.remove("type");
serde_json::from_value(serde_json::Value::Object(map))
})
.map_err(|e| format!("Failed to parse config: {e}"))
}
/// Save the vault-wide UI config to `config/ui.config.md`.
/// Creates the directory and file if they don't exist.
pub fn save_vault_config(vault_path: &str, config: VaultConfig) -> Result<(), String> {
let path = config_path(vault_path);
let dir = Path::new(vault_path).join(CONFIG_DIR);
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let content = serialize_config(&config);
std::fs::write(&path, content).map_err(|e| format!("Failed to write config: {e}"))
}
/// Serialize VaultConfig to a markdown file with YAML frontmatter.
fn serialize_config(config: &VaultConfig) -> String {
let mut lines = vec!["---".to_string(), "type: config".to_string()];
if let Some(zoom) = config.zoom {
lines.push(format!("zoom: {zoom}"));
}
if let Some(ref mode) = config.view_mode {
lines.push(format!("view_mode: {mode}"));
}
if let Some(ref mode) = config.editor_mode {
lines.push(format!("editor_mode: {mode}"));
}
append_string_map(&mut lines, "tag_colors", config.tag_colors.as_ref());
append_string_map(&mut lines, "status_colors", config.status_colors.as_ref());
append_string_map(
&mut lines,
"property_display_modes",
config.property_display_modes.as_ref(),
);
lines.push("---".to_string());
lines.join("\n") + "\n"
}
/// Append a YAML map section with sorted keys for stable output.
fn append_string_map(lines: &mut Vec<String>, key: &str, map: Option<&HashMap<String, String>>) {
if let Some(m) = map {
if !m.is_empty() {
lines.push(format!("{key}:"));
let mut entries: Vec<_> = m.iter().collect();
entries.sort_by_key(|(k, _)| k.to_owned());
for (k, v) in entries {
lines.push(format!(" {}: {}", yaml_safe_key(k), yaml_safe_value(v)));
}
}
}
}
/// Quote a YAML key if it contains special characters.
fn yaml_safe_key(key: &str) -> String {
if key.contains(':') || key.contains('#') || key.contains(' ') {
format!("\"{}\"", key.replace('"', "\\\""))
} else {
key.to_string()
}
}
/// Quote a YAML value if it contains special characters.
fn yaml_safe_value(value: &str) -> String {
if value.contains(':')
|| value.contains('#')
|| value.starts_with('"')
|| value.starts_with('\'')
{
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value.to_string()
}
}
/// Migrate `hidden_sections` from `config/ui.config.md` to `visible: false`
/// on Type notes. Returns the number of Type notes updated.
///
/// For each type name in `hidden_sections`:
/// - If `<slug>.md` exists at vault root, adds `visible: false` to its frontmatter
/// - If it doesn't exist, creates it with `type: Type`, `title: <name>`, `visible: false`
/// - Re-saves the config without `hidden_sections`
pub fn migrate_hidden_sections_to_visible(vault_path: &str) -> Result<usize, String> {
let path = config_path(vault_path);
if !path.exists() {
return Ok(0);
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
let hidden = extract_hidden_sections(&content);
if hidden.is_empty() {
return Ok(0);
}
let vault = Path::new(vault_path);
let mut migrated = 0;
for type_name in &hidden {
let slug = type_name_to_slug(type_name);
let type_path = vault.join(format!("{slug}.md"));
if type_path.exists() {
let type_content = std::fs::read_to_string(&type_path)
.map_err(|e| format!("Failed to read {}: {e}", type_path.display()))?;
if !type_content.contains("visible:") {
let updated = crate::frontmatter::update_frontmatter_content(
&type_content,
"visible",
Some(crate::frontmatter::FrontmatterValue::Bool(false)),
)
.map_err(|e| format!("Failed to update {}: {e}", type_path.display()))?;
std::fs::write(&type_path, updated)
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
}
} else {
let new_content = format!(
"---\ntype: Type\ntitle: {}\nvisible: false\n---\n\n# {}\n",
type_name, type_name
);
std::fs::write(&type_path, new_content)
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
}
migrated += 1;
}
// Re-save config without hidden_sections
let config = parse_vault_config(&content)?;
save_vault_config(vault_path, config)?;
Ok(migrated)
}
/// Extract `hidden_sections` from raw YAML frontmatter.
fn extract_hidden_sections(content: &str) -> Vec<String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let hash = match parsed.data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return vec![],
};
match hash.get("hidden_sections") {
Some(gray_matter::Pod::Array(arr)) => arr
.iter()
.filter_map(|v| match v {
gray_matter::Pod::String(s) => Some(s.clone()),
_ => None,
})
.collect(),
_ => vec![],
}
}
/// Convert a Type name to a filesystem slug.
fn type_name_to_slug(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Convert gray_matter::Pod to serde_json::Value.
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {
gray_matter::Pod::String(s) => serde_json::Value::String(s),
gray_matter::Pod::Integer(i) => serde_json::json!(i),
gray_matter::Pod::Float(f) => serde_json::json!(f),
gray_matter::Pod::Boolean(b) => serde_json::Value::Bool(b),
gray_matter::Pod::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(pod_to_json).collect())
}
gray_matter::Pod::Hash(map) => {
let obj: serde_json::Map<String, serde_json::Value> =
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
serde_json::Value::Object(obj)
}
gray_matter::Pod::Null => serde_json::Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_returns_defaults() {
let config = parse_vault_config("").unwrap();
assert!(config.zoom.is_none());
assert!(config.view_mode.is_none());
assert!(config.tag_colors.is_none());
}
#[test]
fn parse_full_config() {
let content = r#"---
type: config
zoom: 1.1
view_mode: all
tag_colors:
engineering: blue
personal: green
status_colors:
Active: green
Done: blue
property_display_modes:
deadline: date
---
"#;
let config = parse_vault_config(content).unwrap();
assert_eq!(config.zoom, Some(1.1));
assert_eq!(config.view_mode.as_deref(), Some("all"));
let tags = config.tag_colors.unwrap();
assert_eq!(tags.get("engineering").unwrap(), "blue");
assert_eq!(tags.get("personal").unwrap(), "green");
let statuses = config.status_colors.unwrap();
assert_eq!(statuses.get("Active").unwrap(), "green");
let props = config.property_display_modes.unwrap();
assert_eq!(props.get("deadline").unwrap(), "date");
}
#[test]
fn roundtrip_serialization() {
let mut tag_colors = HashMap::new();
tag_colors.insert("work".to_string(), "blue".to_string());
let config = VaultConfig {
zoom: Some(1.2),
view_mode: Some("editor-only".to_string()),
editor_mode: None,
tag_colors: Some(tag_colors),
status_colors: None,
property_display_modes: None,
};
let serialized = serialize_config(&config);
let parsed = parse_vault_config(&serialized).unwrap();
assert_eq!(parsed.zoom, Some(1.2));
assert_eq!(parsed.view_mode.as_deref(), Some("editor-only"));
assert_eq!(parsed.tag_colors.unwrap().get("work").unwrap(), "blue");
}
#[test]
fn get_config_missing_file() {
let dir = tempfile::TempDir::new().unwrap();
let config = get_vault_config(dir.path().to_str().unwrap()).unwrap();
assert!(config.zoom.is_none());
}
#[test]
fn save_and_read_config() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let mut status_colors = HashMap::new();
status_colors.insert("Active".to_string(), "green".to_string());
let config = VaultConfig {
zoom: Some(0.9),
view_mode: None,
editor_mode: None,
tag_colors: None,
status_colors: Some(status_colors),
property_display_modes: None,
};
save_vault_config(vault_path, config).unwrap();
let loaded = get_vault_config(vault_path).unwrap();
assert_eq!(loaded.zoom, Some(0.9));
assert_eq!(
loaded.status_colors.unwrap().get("Active").unwrap(),
"green"
);
}
#[test]
fn migrate_hidden_sections_creates_type_notes_with_visible_false() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Bookmark\n - Recipe\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 2);
// Check type notes were created
let bookmark = std::fs::read_to_string(dir.path().join("bookmark.md")).unwrap();
assert!(bookmark.contains("visible: false"));
assert!(bookmark.contains("title: Bookmark"));
let recipe = std::fs::read_to_string(dir.path().join("recipe.md")).unwrap();
assert!(recipe.contains("visible: false"));
// Config should no longer have hidden_sections
let config_content = std::fs::read_to_string(config_dir.join("ui.config.md")).unwrap();
assert!(!config_content.contains("hidden_sections"));
}
#[test]
fn migrate_hidden_sections_updates_existing_type_note() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Project\n---\n",
)
.unwrap();
// Create existing type note at vault root without visible
std::fs::write(
dir.path().join("project.md"),
"---\ntype: Type\ntitle: Project\nicon: briefcase\n---\n\n# Project\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(dir.path().join("project.md")).unwrap();
assert!(content.contains("visible: false"));
assert!(
content.contains("icon: briefcase"),
"should preserve existing fields"
);
}
#[test]
fn migrate_hidden_sections_skips_when_no_hidden_sections() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nzoom: 1.0\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrate_hidden_sections_skips_when_no_config_file() {
let dir = tempfile::TempDir::new().unwrap();
let count = migrate_hidden_sections_to_visible(dir.path().to_str().unwrap()).unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrate_hidden_sections_does_not_duplicate_visible() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Note\n---\n",
)
.unwrap();
// Type note already has visible: false
let type_dir = dir.path().join("type");
std::fs::create_dir_all(&type_dir).unwrap();
std::fs::write(
type_dir.join("note.md"),
"---\ntype: Type\ntitle: Note\nvisible: false\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(type_dir.join("note.md")).unwrap();
// Should have exactly one visible: false, not two
assert_eq!(content.matches("visible:").count(), 1);
}
#[test]
fn type_name_to_slug_converts_names() {
assert_eq!(type_name_to_slug("Project"), "project");
assert_eq!(type_name_to_slug("Weekly Review"), "weekly-review");
assert_eq!(type_name_to_slug("My Note!"), "my-note");
}
#[test]
fn yaml_safe_key_quoting() {
assert_eq!(yaml_safe_key("simple"), "simple");
assert_eq!(yaml_safe_key("has space"), "\"has space\"");
assert_eq!(yaml_safe_key("has:colon"), "\"has:colon\"");
}
}

View File

@@ -7,7 +7,7 @@
"frontendDist": "../dist",
"devUrl": "http://localhost:5202",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp && bash scripts/bundle-qmd.sh"
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp"
},
"app": {
"withGlobalTauri": true,
@@ -38,8 +38,7 @@
"targets": "all",
"createUpdaterArtifacts": true,
"resources": {
"resources/mcp-server/**/*": "mcp-server/",
"resources/qmd/**/*": "qmd/"
"resources/mcp-server/**/*": "mcp-server/"
},
"icon": [
"icons/32x32.png",

View File

@@ -30,12 +30,10 @@ import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater, restartApp } from './hooks/useUpdater'
import { useAutoSync } from './hooks/useAutoSync'
import { useConflictResolver } from './hooks/useConflictResolver'
import { useIndexing } from './hooks/useIndexing'
import { useZoom } from './hooks/useZoom'
import { useVaultConfig } from './hooks/useVaultConfig'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useAppNavigation } from './hooks/useAppNavigation'
import { useAiActivity } from './hooks/useAiActivity'
@@ -49,10 +47,11 @@ import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner'
import { useFlatVaultMigration } from './hooks/useFlatVaultMigration'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, VaultEntry } from './types'
import type { SidebarSelection, VaultEntry, InboxPeriod } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries, type NoteListFilter } from './utils/noteListHelpers'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openLocalFile } from './utils/url'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { flushEditorContent } from './utils/autoSave'
import './App.css'
@@ -71,6 +70,7 @@ const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
function App() {
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
const [inboxPeriod, setInboxPeriod] = useState<InboxPeriod>('month')
const handleSetSelection = useCallback((sel: SidebarSelection) => {
setSelection(sel)
setNoteListFilter('open')
@@ -94,18 +94,13 @@ function App() {
const vault = useVaultLoader(resolvedPath)
useVaultConfig(resolvedPath)
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath, vault.entries)
const flatVaultMigration = useFlatVaultMigration(resolvedPath, vault.entries.length > 0, vault.reloadVault)
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const indexing = useIndexing(resolvedPath)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: vault.reloadVault,
onSyncUpdated: indexing.triggerIncrementalIndex,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names} — click to resolve`)
@@ -155,11 +150,40 @@ function App() {
dialogs.closeConflictResolver()
}, [autoSync, dialogs])
/** Resolve a single file conflict from the in-editor banner. */
const handleResolveConflictInline = useCallback(async (filePath: string, strategy: 'ours' | 'theirs') => {
try {
const relativePath = filePath.replace(resolvedPath + '/', '')
const call = isTauri() ? invoke : mockInvoke
await call('git_resolve_conflict', { vaultPath: resolvedPath, file: relativePath, strategy })
// Reload the note content to show the resolved version
const content = await (isTauri() ? invoke<string> : mockInvoke<string>)('get_note_content', { path: filePath })
// Check remaining conflicts
const remaining = await (isTauri() ? invoke<string[]> : mockInvoke<string[]>)('get_conflict_files', { vaultPath: resolvedPath })
if (remaining.length === 0) {
// All resolved — auto-commit the merge and push
await (isTauri() ? invoke : mockInvoke)('git_commit_conflict_resolution', { vaultPath: resolvedPath })
vault.reloadVault()
autoSync.triggerSync()
setToastMessage('All conflicts resolved — merge committed')
} else {
void content // content reload happens via vault reload
vault.reloadVault()
setToastMessage(`Resolved — ${remaining.length} conflict${remaining.length > 1 ? 's' : ''} remaining`)
}
} catch (err) {
setToastMessage(`Failed to resolve conflict: ${err}`)
}
}, [resolvedPath, vault, autoSync, setToastMessage])
const handleKeepMine = useCallback((path: string) => handleResolveConflictInline(path, 'ours'), [handleResolveConflictInline])
const handleKeepTheirs = useCallback((path: string) => handleResolveConflictInline(path, 'theirs'), [handleResolveConflictInline])
// Ref bridges handleContentChange (created after notes) into useNoteActions.
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterContentChanged: themeManager.notifyThemeSaved })
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
// Keep tab entries in sync with vault entries so banners (trash/archive)
// and read-only state react immediately without reopening the note.
@@ -255,17 +279,25 @@ function App() {
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
}, [notes.activeTabPath, handleRemoveNoteIcon])
const { triggerIncrementalIndex } = indexing
/** Open the active note in a new window (command palette / keyboard shortcut). */
const handleOpenInNewWindow = useCallback(() => {
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
if (activeTab) openNoteInNewWindow(activeTab.entry.path, resolvedPath, activeTab.entry.title)
}, [notes.tabs, notes.activeTabPath, resolvedPath])
/** Open a specific note entry in a new window (Cmd+Shift+Click). */
const handleOpenEntryInNewWindow = useCallback((entry: VaultEntry) => {
openNoteInNewWindow(entry.path, resolvedPath, entry.title)
}, [resolvedPath])
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
triggerIncrementalIndex()
}, [vault, triggerIncrementalIndex])
}, [vault])
const { notifyThemeSaved } = themeManager
const onNotePersisted = useCallback((path: string, content: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- signature required by useEditorSave
const onNotePersisted = useCallback((path: string, _content: string) => {
vault.clearUnsaved(path)
notifyThemeSaved(path, content)
}, [vault, notifyThemeSaved])
}, [vault])
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateEntry: vault.updateEntry,
@@ -313,6 +345,22 @@ function App() {
}
}, [resolvedPath, vault.entries, notes, dialogs])
/** Flush pending auto-save before closing a tab to prevent data loss. */
const handleCloseTabWithFlush = useCallback((path: string) => {
savePendingForPath(path).catch(() => {})
notes.handleCloseTab(path)
}, [savePendingForPath, notes])
// Wrap the close-tab ref so Cmd+W and menu bar also flush auto-save
const closeTabWithFlushRef = useRef<(path: string) => void>(handleCloseTabWithFlush)
useEffect(() => {
const original = notes.handleCloseTabRef.current
closeTabWithFlushRef.current = (path: string) => {
savePendingForPath(path).catch(() => {})
original(path)
}
})
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
await savePendingForPath(path)
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
@@ -333,7 +381,7 @@ function App() {
}
}, [handleSaveRaw, handleRenameTab, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
const entryActions = useEntryActions({
entries: vault.entries, updateEntry: vault.updateEntry,
@@ -398,35 +446,21 @@ function App() {
// 'available' → UpdateBanner handles it automatically
}, [updateActions, updateStatus.state, setToastMessage])
const handleRestoreDefaultThemes = useCallback(async () => {
if (!resolvedPath) return
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const msg = await tauriInvoke<string>('restore_default_themes', { vaultPath: resolvedPath })
await vault.reloadVault()
await themeManager.reloadThemes()
setToastMessage(msg)
} catch (err) {
setToastMessage(`Failed to restore themes: ${err}`)
}
}, [resolvedPath, vault, themeManager, setToastMessage])
const handleRepairVault = useCallback(async () => {
if (!resolvedPath) return
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
await vault.reloadVault()
await themeManager.reloadThemes()
setToastMessage(msg)
} catch (err) {
setToastMessage(`Failed to repair vault: ${err}`)
}
}, [resolvedPath, vault, themeManager, setToastMessage])
}, [resolvedPath, vault, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
handleCloseTabRef: closeTabWithFlushRef, tabs: notes.tabs,
entries: vault.entries,
modifiedCount: vault.modifiedFiles.length,
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
@@ -441,6 +475,7 @@ function App() {
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog,
onPull: autoSync.triggerSync,
onResolveConflicts: handleOpenConflictResolver,
onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
@@ -453,28 +488,12 @@ function App() {
onSelectNote: notes.handleSelectNote,
onGoBack: handleGoBack, onGoForward: handleGoForward,
canGoBack: canGoBack, canGoForward: canGoForward,
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
onSwitchTheme: themeManager.switchTheme,
onCreateTheme: async () => {
const path = await themeManager.createTheme()
const freshEntries = await vault.reloadVault()
handleSetSelection({ kind: 'sectionGroup', type: 'Theme' })
if (path) {
const entry = freshEntries.find(e => e.path === path)
if (entry) notes.handleSelectNote(entry)
}
},
onOpenTheme: (themeId: string) => {
const entry = vault.entries.find(e => e.path === themeId)
if (entry) notes.handleSelectNote(entry)
},
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
onRestoreDefaultThemes: handleRestoreDefaultThemes,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
@@ -482,7 +501,6 @@ function App() {
onEmptyTrash: deleteActions.handleEmptyTrash,
trashedCount: deleteActions.trashedCount,
onReopenClosedTab: notes.handleReopenClosedTab,
onReindexVault: indexing.triggerFullReindex,
onReloadVault: vault.reloadVault,
onRepairVault: handleRepairVault,
onSetNoteIcon: handleSetNoteIconCommand,
@@ -493,15 +511,20 @@ function App() {
})(),
noteListFilter,
onSetNoteListFilter: setNoteListFilter,
onOpenInNewWindow: handleOpenInNewWindow,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod])
const aiNoteList = useMemo<NoteListItem[]>(() => {
return filterEntries(vault.entries, selection).map(e => ({
const isInbox = selection.kind === 'filter' && selection.filter === 'inbox'
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection)
return filtered.map(e => ({
path: e.path, title: e.title, type: e.isA ?? 'Note',
}))
}, [vault.entries, selection])
}, [vault.entries, selection, inboxPeriod])
const aiNoteListFilter = useMemo(() => {
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
@@ -525,7 +548,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} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
<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} modifiedCount={vault.modifiedFiles.length} inboxCount={inboxCount} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -536,7 +559,7 @@ function App() {
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -548,7 +571,7 @@ function App() {
activeTabPath={notes.activeTabPath}
entries={vault.entries}
onSwitchTab={notes.handleSwitchTab}
onCloseTab={notes.handleCloseTab}
onCloseTab={handleCloseTabWithFlush}
onReorderTabs={notes.handleReorderTabs}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
@@ -587,12 +610,14 @@ function App() {
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
isDarkTheme={themeManager.isDark}
onFileCreated={handleAgentFileCreated}
onFileModified={handleAgentFileModified}
onVaultChanged={handleAgentVaultChanged}
onSetNoteIcon={handleSetNoteIcon}
onRemoveNoteIcon={handleRemoveNoteIcon}
isConflicted={!!notes.activeTabPath && autoSync.conflictFiles.some(f => notes.activeTabPath?.endsWith(f))}
onKeepMine={handleKeepMine}
onKeepTheirs={handleKeepTheirs}
/>
</div>
</div>
@@ -608,7 +633,7 @@ function App() {
/>
)}
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<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' })} 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={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} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
@@ -626,7 +651,7 @@ function App() {
onCommit={conflictResolver.commitResolution}
onClose={handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} themeManager={themeManager} />
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
<GitHubVaultModal
open={dialogs.showGitHubVault}
githubToken={settings.github_token}

166
src/NoteWindow.tsx Normal file
View File

@@ -0,0 +1,166 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { Editor } from './components/Editor'
import { Toast } from './components/Toast'
import { isTauri, mockInvoke } from './mock-tauri'
import { getNoteWindowParams } from './utils/windowMode'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import type { VaultEntry } from './types'
import './App.css'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
interface Tab {
entry: VaultEntry
content: string
}
/**
* Minimal app shell for secondary "note windows" opened via "Open in New Window".
* Shows only the editor — no sidebar, no note list.
*/
export default function NoteWindow() {
const params = getNoteWindowParams()
const [entries, setEntries] = useState<VaultEntry[]>([])
const [tabs, setTabs] = useState<Tab[]>([])
const [toastMessage, setToastMessage] = useState<string | null>(null)
const activeTabPath = tabs[0]?.entry.path ?? null
const layout = useLayoutPanels()
// Load vault entries + note content on mount
useEffect(() => {
if (!params) return
const { vaultPath, notePath } = params
let cancelled = false
async function load() {
const vaultEntries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
if (cancelled) return
setEntries(vaultEntries)
const entry = vaultEntries.find(e => e.path === notePath)
if (!entry) return
const content = await tauriCall<string>('get_note_content', { path: notePath })
if (cancelled) return
setTabs([{ entry, content }])
}
load().catch(err => console.error('NoteWindow load failed:', err))
return () => { cancelled = true }
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount with captured params
const vaultPath = params?.vaultPath ?? ''
// Update window title when note title changes
useEffect(() => {
const title = tabs[0]?.entry.title
if (!title) return
if (!isTauri()) { document.title = title; return }
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
getCurrentWindow().setTitle(title)
}).catch(() => {})
}, [tabs])
// Auto-save
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
setEntries(prev => prev.map(e => e.path === path ? { ...e, ...patch } : e))
setTabs(prev => prev.map(t => t.entry.path === path ? { ...t, entry: { ...t.entry, ...patch } } : t))
}, [])
const onAfterSave = useCallback(() => {}, [])
const onNotePersisted = useCallback(() => {}, [])
const { handleSave, handleContentChange } = useEditorSaveWithLinks({
updateEntry,
setTabs,
setToastMessage,
onAfterSave,
onNotePersisted,
})
// Wikilink navigation — in a note window, open wikilinks in the same window
const handleNavigateWikilink = useCallback((target: string) => {
const targetLower = target.toLowerCase()
const entry = entries.find(e =>
e.title.toLowerCase() === targetLower ||
e.aliases.some(a => a.toLowerCase() === targetLower)
)
if (!entry) return
tauriCall<string>('get_note_content', { path: entry.path }).then(content => {
setTabs([{ entry, content }])
}).catch(() => {})
}, [entries])
// Stub for close tab — in a note window, close the window
const handleCloseTab = useCallback(() => {
if (!isTauri()) return
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
getCurrentWindow().close()
}).catch(() => {})
}, [])
// Keyboard: Cmd+S to save, Cmd+W to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
if ((e.metaKey || e.ctrlKey) && e.key === 'w') {
e.preventDefault()
handleCloseTab()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSave, handleCloseTab])
const activeTab = tabs[0] ?? null
const gitHistory = useMemo(() => [], [])
if (!params) {
return <div className="app-shell"><p>Invalid note window parameters</p></div>
}
if (!activeTab) {
return (
<div className="app-shell">
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading</span>
</div>
</div>
)
}
return (
<div className="app-shell">
<div className="app">
<div className="app__editor">
<Editor
tabs={tabs}
activeTabPath={activeTabPath}
entries={entries}
onSwitchTab={() => {}}
onCloseTab={handleCloseTab}
onNavigateWikilink={handleNavigateWikilink}
inspectorCollapsed={layout.inspectorCollapsed}
onToggleInspector={() => layout.setInspectorCollapsed(c => !c)}
inspectorWidth={layout.inspectorWidth}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab.entry}
inspectorContent={activeTab.content}
gitHistory={gitHistory}
onContentChange={handleContentChange}
onSave={handleSave}
leftPanelsCollapsed={true}
vaultPath={vaultPath}
/>
</div>
</div>
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
</div>
)
}

View File

@@ -170,7 +170,6 @@ describe('CommandPalette', () => {
const relevanceCommands: CommandAction[] = [
makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note' }),
makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }),
makeCommand({ id: 'switch-theme', label: 'Switch Theme', group: 'Appearance', keywords: ['dark', 'light'] }),
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }),
]
@@ -203,14 +202,6 @@ describe('CommandPalette', () => {
expect(labels[0]).toBe('Create New Note')
})
it('ranks theme commands first for query "theme"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'theme' } })
const labels = getVisibleLabels()
expect(labels[0]).toBe('Switch Theme')
})
it('preserves default section order with empty query', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
@@ -221,8 +212,8 @@ describe('CommandPalette', () => {
!!el.textContent,
).map(el => el.textContent)
// Default order: Navigation < Note < View < Appearance
expect(groupHeaders).toEqual(['Navigation', 'Note', 'View', 'Appearance'])
// Default order: Navigation < Note < View
expect(groupHeaders).toEqual(['Navigation', 'Note', 'View'])
})
})
})

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ConflictNoteBanner } from './ConflictNoteBanner'
describe('ConflictNoteBanner', () => {
it('renders conflict message', () => {
render(<ConflictNoteBanner onKeepMine={vi.fn()} onKeepTheirs={vi.fn()} />)
expect(screen.getByText('This note has a merge conflict')).toBeInTheDocument()
})
it('calls onKeepMine when clicking Keep mine button', () => {
const onKeepMine = vi.fn()
render(<ConflictNoteBanner onKeepMine={onKeepMine} onKeepTheirs={vi.fn()} />)
fireEvent.click(screen.getByTestId('conflict-keep-mine-btn'))
expect(onKeepMine).toHaveBeenCalledOnce()
})
it('calls onKeepTheirs when clicking Keep theirs button', () => {
const onKeepTheirs = vi.fn()
render(<ConflictNoteBanner onKeepMine={vi.fn()} onKeepTheirs={onKeepTheirs} />)
fireEvent.click(screen.getByTestId('conflict-keep-theirs-btn'))
expect(onKeepTheirs).toHaveBeenCalledOnce()
})
it('has the correct test id', () => {
render(<ConflictNoteBanner onKeepMine={vi.fn()} onKeepTheirs={vi.fn()} />)
expect(screen.getByTestId('conflict-note-banner')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,68 @@
import { AlertTriangle } from 'lucide-react'
interface ConflictNoteBannerProps {
onKeepMine: () => void
onKeepTheirs: () => void
}
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBannerProps) {
return (
<div
data-testid="conflict-note-banner"
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 16px',
background: 'var(--muted)',
borderBottom: '1px solid var(--border)',
fontSize: 12,
color: 'var(--accent-orange)',
flexShrink: 0,
}}
>
<AlertTriangle size={13} />
<span>This note has a merge conflict</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<button
data-testid="conflict-keep-mine-btn"
onClick={onKeepMine}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 4,
fontSize: 11,
color: 'var(--foreground)',
cursor: 'pointer',
}}
title="Keep my local version"
>
Keep mine
</button>
<button
data-testid="conflict-keep-theirs-btn"
onClick={onKeepTheirs}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 4,
fontSize: 11,
color: 'var(--foreground)',
cursor: 'pointer',
}}
title="Keep the remote version"
>
Keep theirs
</button>
</div>
</div>
)
}

View File

@@ -664,6 +664,39 @@ describe('DynamicPropertiesPanel', () => {
})
})
describe('property row 50/50 layout', () => {
it('uses CSS grid with two equal columns on editable rows', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ url: 'https://example.com/very/long/path/that/should/be/truncated' }}
onUpdateProperty={onUpdateProperty}
/>
)
const editableRows = screen.getAllByTestId('editable-property')
editableRows.forEach(row => {
expect(row.className).toContain('grid')
expect(row.className).toContain('grid-cols-2')
})
})
it('uses CSS grid with two equal columns on read-only rows', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{}}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
readOnlyRows.forEach(row => {
expect(row.className).toContain('grid')
expect(row.className).toContain('grid-cols-2')
})
})
})
describe('URL property rendering', () => {
it('renders URL values with link styling instead of plain EditableValue', () => {
render(

View File

@@ -47,7 +47,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
}
return (
<div className="group/prop flex min-w-0 items-center justify-between gap-2 rounded px-1.5 py-0.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-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="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
<span className="truncate">{propKey}</span>
{onDelete && (
@@ -55,15 +55,17 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
)}
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
</span>
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
<div className="min-w-0">
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} vaultTags={vaultTags} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
</div>
</div>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="readonly-property">
<span className="font-mono-overline shrink-0" style={{ color: 'var(--text-muted)' }}>{label}</span>
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="readonly-property">
<span className="font-mono-overline min-w-0 truncate" style={{ color: 'var(--text-muted)' }}>{label}</span>
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
</div>
)
@@ -116,7 +118,7 @@ export function DynamicPropertiesPanel({
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1.5">
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
{propertyEntries.map(([key, value]) => (
<PropertyRow

View File

@@ -96,7 +96,7 @@
.editor__blocknote-container .bn-editor {
width: 100%;
padding: 20px 40px;
max-width: 760px;
max-width: var(--editor-max-width, 760px);
margin: 0 auto;
}
@@ -282,11 +282,13 @@
border: none;
outline: none;
background: transparent;
font-size: var(--editor-title-size, 28px);
font-weight: 700;
line-height: 1.2;
font-size: var(--headings-h1-font-size);
font-weight: var(--headings-h1-font-weight);
line-height: var(--headings-h1-line-height);
letter-spacing: var(--headings-h1-letter-spacing);
color: var(--foreground);
padding: 0;
margin-left: 8px;
}
.title-field__input::placeholder {

View File

@@ -300,7 +300,7 @@ describe('Editor', () => {
const trashedTab = { entry: trashedEntry, content: mockContent }
function renderTrashed(overrides: Partial<Parameters<typeof Editor>[0]> = {}) {
return render(<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
return render(<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
}
it('shows banner and read-only editor when note is trashed', () => {
@@ -336,9 +336,10 @@ describe('Editor', () => {
)
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
const updatedTab = { entry: { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }, content: mockContent }
const trashedEntryUpdated = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
const updatedTab = { entry: trashedEntryUpdated, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
<Editor {...defaultProps} entries={[trashedEntryUpdated]} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
@@ -346,13 +347,14 @@ describe('Editor', () => {
it('removes trash banner immediately when entry is restored (reactive)', () => {
const { rerender } = render(
<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
const restoredTab = { entry: { ...trashedEntry, trashed: false, trashedAt: null }, content: mockContent }
const restoredEntry = { ...trashedEntry, trashed: false, trashedAt: null }
const restoredTab = { entry: restoredEntry, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
<Editor {...defaultProps} entries={[restoredEntry]} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
@@ -408,6 +410,7 @@ describe('click empty editor space', () => {
render(
<Editor
{...defaultProps}
entries={[trashedEntry]}
tabs={[{ entry: trashedEntry, content: mockContent }]}
activeTabPath={trashedEntry.path}
/>
@@ -429,9 +432,10 @@ describe('archived note behavior', () => {
)
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
const archivedTab = { entry: { ...mockEntry, archived: true }, content: mockContent }
const archivedEntry = { ...mockEntry, archived: true }
const archivedTab = { entry: archivedEntry, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
})
@@ -440,13 +444,14 @@ describe('archived note behavior', () => {
const archivedEntry: VaultEntry = { ...mockEntry, archived: true }
const archivedTab = { entry: archivedEntry, content: mockContent }
const { rerender } = render(
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
const unarchivedTab = { entry: { ...archivedEntry, archived: false }, content: mockContent }
const unarchivedEntry = { ...archivedEntry, archived: false }
const unarchivedTab = { entry: unarchivedEntry, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
<Editor {...defaultProps} entries={[unarchivedEntry]} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
})

View File

@@ -65,7 +65,6 @@ interface EditorProps {
onGoBack?: () => void
onGoForward?: () => void
leftPanelsCollapsed?: boolean
isDarkTheme?: boolean
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
rawToggleRef?: React.MutableRefObject<() => void>
/** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */
@@ -77,6 +76,12 @@ interface EditorProps {
onSetNoteIcon?: (path: string, emoji: string) => void
/** Called when user removes an emoji icon from a note. */
onRemoveNoteIcon?: (path: string) => void
/** Whether the active note has a merge conflict. */
isConflicted?: boolean
/** Resolve conflict by keeping the local version. */
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
onKeepTheirs?: (path: string) => void
}
function useEditorModeExclusion({
@@ -222,8 +227,9 @@ export const Editor = memo(function Editor(props: EditorProps) {
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
isDarkTheme, onFileCreated, onFileModified, onVaultChanged,
onFileCreated, onFileModified, onVaultChanged,
onSetNoteIcon, onRemoveNoteIcon,
isConflicted, onKeepMine, onKeepTheirs,
} = props
const {
@@ -286,11 +292,13 @@ export const Editor = memo(function Editor(props: EditorProps) {
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
isDarkTheme={isDarkTheme}
rawLatestContentRef={rawLatestContentRef}
onTitleChange={onTitleSync}
onSetNoteIcon={onSetNoteIcon}
onRemoveNoteIcon={onRemoveNoteIcon}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
/>
}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}

View File

@@ -8,6 +8,7 @@ import { TitleField } from './TitleField'
import { NoteIcon } from './NoteIcon'
import { TrashedNoteBanner } from './TrashedNoteBanner'
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
import { ConflictNoteBanner } from './ConflictNoteBanner'
import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
@@ -45,7 +46,6 @@ interface EditorContentProps {
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
isDarkTheme?: boolean
/** Ref updated by RawEditorView on every keystroke with the latest doc. */
rawLatestContentRef?: React.MutableRefObject<string | null>
/** Called when the user edits the dedicated title field. */
@@ -54,6 +54,12 @@ interface EditorContentProps {
onSetNoteIcon?: (path: string, emoji: string) => void
/** Called when user removes an emoji icon. */
onRemoveNoteIcon?: (path: string) => void
/** Whether the active note has a merge conflict. */
isConflicted?: boolean
/** Resolve conflict by keeping the local version. */
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
onKeepTheirs?: (path: string) => void
}
function EditorLoadingSkeleton() {
@@ -85,14 +91,13 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
}
function RawModeEditorSection({
rawMode, activeTab, entries, onContentChange, onSave, isDark, latestContentRef,
rawMode, activeTab, entries, onContentChange, onSave, latestContentRef,
}: {
rawMode: boolean
activeTab: Tab | null
entries: VaultEntry[]
onContentChange?: (path: string, content: string) => void
onSave?: () => void
isDark?: boolean
latestContentRef?: React.MutableRefObject<string | null>
}) {
if (!rawMode || !activeTab) return null
@@ -104,7 +109,6 @@ function RawModeEditorSection({
entries={entries}
onContentChange={onContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
isDark={isDark}
latestContentRef={latestContentRef}
/>
)
@@ -148,12 +152,17 @@ export function EditorContent({
activeTab, isLoadingNewTab, entries, editor,
diffMode, diffContent, onToggleDiff,
rawMode, onToggleRaw, onRawContentChange, onSave,
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
onNavigateWikilink, onEditorChange, vaultPath,
onDeleteNote, rawLatestContentRef, onTitleChange,
onSetNoteIcon, onRemoveNoteIcon,
isConflicted, onKeepMine, onKeepTheirs,
...breadcrumbProps
}: EditorContentProps) {
const isTrashed = activeTab?.entry.trashed ?? false
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
// so the banner appears regardless of navigation context.
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
const showEditor = !diffMode && !rawMode
const entryIcon = activeTab?.entry.icon ?? null
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
@@ -180,11 +189,17 @@ export function EditorContent({
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
/>
)}
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
{activeTab && isArchived && breadcrumbProps.onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
)}
{activeTab && isConflicted && (
<ConflictNoteBanner
onKeepMine={() => onKeepMine?.(activeTab.entry.path)}
onKeepTheirs={() => onKeepTheirs?.(activeTab.entry.path)}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} latestContentRef={rawLatestContentRef} />
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} />
{showEditor && activeTab && (
<div className="editor-scroll-area">
<div className="title-section">
@@ -202,7 +217,7 @@ export function EditorContent({
/>
<div className="title-section__separator" />
</div>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable={!isTrashed} />
</div>
)}
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}

View File

@@ -1,7 +1,7 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NoteList } from './NoteList'
import { getSortComparator, filterEntries, countByFilter } from '../utils/noteListHelpers'
import { getSortComparator, filterEntries, countByFilter, countAllByFilter } from '../utils/noteListHelpers'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { VaultEntry, SidebarSelection } from '../types'
@@ -154,6 +154,29 @@ const mockEntries: VaultEntry[] = [
},
]
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
...overrides,
})
const makeIndexedEntry = (i: number, overrides?: Partial<VaultEntry>): VaultEntry =>
makeEntry({
path: `/vault/note/note-${i}.md`,
filename: `note-${i}.md`,
title: `Note ${i}`,
isA: 'Note',
modifiedAt: 1700000000 - i * 60,
fileSize: 500,
snippet: `Content of note ${i}`,
...overrides,
})
describe('NoteList', () => {
it('shows empty state when no entries', () => {
render(<NoteList {...defaultFilterProps} entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
@@ -348,35 +371,6 @@ describe('NoteList click behavior', () => {
})
describe('getSortComparator', () => {
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md',
filename: 'test.md',
title: 'Test',
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: null,
createdAt: null,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
})
it('sorts by modified date descending', () => {
const a = makeEntry({ title: 'A', modifiedAt: 1000 })
const b = makeEntry({ title: 'B', modifiedAt: 3000 })
@@ -462,35 +456,6 @@ describe('NoteList sort controls', () => {
try { localStorage.removeItem('laputa-sort-preferences') } catch { /* noop */ }
})
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md',
filename: 'test.md',
title: 'Test',
isA: null,
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: null,
createdAt: null,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
})
it('shows sort button in note list header for flat view', () => {
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
@@ -505,11 +470,21 @@ describe('NoteList sort controls', () => {
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
})
it('opens sort menu on click and shows all options', () => {
const renderListAndOpenSort = (entries: VaultEntry[] = mockEntries) => {
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
}
const zamEntries = [
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
it('opens sort menu on click and shows all options', () => {
renderListAndOpenSort()
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-modified')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-created')).toBeInTheDocument()
@@ -518,20 +493,12 @@ describe('NoteList sort controls', () => {
})
it('changes sort order when an option is selected', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
render(
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// Default sort: by modified (Zebra first)
renderListAndOpenSort(zamEntries)
// Default sort: by modified (Zebra first) — menu is already open
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
// Switch to title sort
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-option-title'))
// Now should be alphabetical
@@ -540,21 +507,14 @@ describe('NoteList sort controls', () => {
})
it('closes sort menu after selecting an option', () => {
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
renderListAndOpenSort()
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('sort-option-title'))
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
})
it('shows direction arrows in sort dropdown menu', () => {
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
// Each option should have asc and desc direction buttons
renderListAndOpenSort()
expect(screen.getByTestId('sort-dir-asc-modified')).toBeInTheDocument()
expect(screen.getByTestId('sort-dir-desc-modified')).toBeInTheDocument()
expect(screen.getByTestId('sort-dir-asc-title')).toBeInTheDocument()
@@ -562,20 +522,12 @@ describe('NoteList sort controls', () => {
})
it('reverses sort order when clicking direction arrow', () => {
const entries = [
makeEntry({ path: '/a.md', title: 'Zebra', modifiedAt: 3000 }),
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
render(
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
renderListAndOpenSort(zamEntries)
// Default sort: modified descending (Zebra first at 3000)
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
expect(titles).toEqual(['Zebra', 'Middle', 'Alpha'])
// Click the asc arrow for modified to reverse
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.click(screen.getByTestId('sort-dir-asc-modified'))
// Now ascending: Alpha (1000) first
@@ -897,37 +849,8 @@ describe('NoteList — trash view', () => {
// --- Virtual list performance tests ---
describe('NoteList — virtual list with large datasets', () => {
const makeEntry = (i: number, overrides?: Partial<VaultEntry>): VaultEntry => ({
path: `/vault/note/note-${i}.md`,
filename: `note-${i}.md`,
title: `Note ${i}`,
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000 - i * 60,
createdAt: null,
fileSize: 500,
snippet: `Content of note ${i}`,
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
})
it('renders 9000 entries without crashing', { timeout: 30000 }, () => {
const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i))
const largeDataset = Array.from({ length: 9000 }, (_, i) => makeIndexedEntry(i))
const { container } = render(
<NoteList {...defaultFilterProps} entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
@@ -936,7 +859,7 @@ describe('NoteList — virtual list with large datasets', () => {
})
it('renders items from a large dataset via Virtuoso', () => {
const largeDataset = Array.from({ length: 500 }, (_, i) => makeEntry(i))
const largeDataset = Array.from({ length: 500 }, (_, i) => makeIndexedEntry(i))
render(
<NoteList {...defaultFilterProps} entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
@@ -946,9 +869,9 @@ describe('NoteList — virtual list with large datasets', () => {
it('search filters large dataset correctly', () => {
const entries = [
makeEntry(0, { title: 'Alpha Strategy' }),
...Array.from({ length: 998 }, (_, i) => makeEntry(i + 1, { title: `Filler Note ${i + 1}` })),
makeEntry(999, { title: 'Beta Strategy' }),
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
...Array.from({ length: 998 }, (_, i) => makeIndexedEntry(i + 1, { title: `Filler Note ${i + 1}` })),
makeIndexedEntry(999, { title: 'Beta Strategy' }),
]
render(
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
@@ -962,9 +885,9 @@ describe('NoteList — virtual list with large datasets', () => {
it('sorting works with large dataset', () => {
const entries = [
makeEntry(0, { title: 'Zebra', modifiedAt: 1000 }),
makeEntry(1, { title: 'Alpha', modifiedAt: 3000 }),
...Array.from({ length: 100 }, (_, i) => makeEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })),
makeIndexedEntry(0, { title: 'Zebra', modifiedAt: 1000 }),
makeIndexedEntry(1, { title: 'Alpha', modifiedAt: 3000 }),
...Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })),
]
render(
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
@@ -976,8 +899,8 @@ describe('NoteList — virtual list with large datasets', () => {
it('section group filter works with large mixed-type dataset', () => {
const entries = [
...Array.from({ length: 100 }, (_, i) => makeEntry(i, { isA: 'Project', title: `Project ${i}` })),
...Array.from({ length: 200 }, (_, i) => makeEntry(100 + i, { isA: 'Note', title: `Note ${i}` })),
...Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i, { isA: 'Project', title: `Project ${i}` })),
...Array.from({ length: 200 }, (_, i) => makeIndexedEntry(100 + i, { isA: 'Note', title: `Note ${i}` })),
]
render(
<NoteList {...defaultFilterProps} entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
@@ -987,7 +910,7 @@ describe('NoteList — virtual list with large datasets', () => {
})
it('selection highlighting works in virtualized list', () => {
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
const entries = Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i))
const selected = entries[5]
render(
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
@@ -997,7 +920,7 @@ describe('NoteList — virtual list with large datasets', () => {
it('click handler works on virtualized items', () => {
noopReplace.mockClear()
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
const entries = Array.from({ length: 100 }, (_, i) => makeIndexedEntry(i))
render(
<NoteList {...defaultFilterProps} entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
@@ -1197,68 +1120,34 @@ describe('NoteList — multi-select', () => {
expect(screen.getByText('2 selected')).toBeInTheDocument()
})
it('bulk archive calls onBulkArchive and clears selection', () => {
const onBulkArchive = vi.fn()
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
const selectTwoNotes = (extraProps: Record<string, unknown> = {}) => {
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} {...extraProps} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByTestId('bulk-archive-btn'))
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
}
it('bulk trash calls onBulkTrash and clears selection', () => {
const onBulkTrash = vi.fn()
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByTestId('bulk-trash-btn'))
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
it.each([
{ label: 'bulk archive via button', prop: 'onBulkArchive', trigger: () => fireEvent.click(screen.getByTestId('bulk-archive-btn')) },
{ label: 'bulk trash via button', prop: 'onBulkTrash', trigger: () => fireEvent.click(screen.getByTestId('bulk-trash-btn')) },
{ label: 'Cmd+E archives', prop: 'onBulkArchive', trigger: () => fireEvent.keyDown(window, { key: 'e', metaKey: true }) },
{ label: 'Cmd+Backspace trashes', prop: 'onBulkTrash', trigger: () => fireEvent.keyDown(window, { key: 'Backspace', metaKey: true }) },
{ label: 'Cmd+Delete trashes', prop: 'onBulkTrash', trigger: () => fireEvent.keyDown(window, { key: 'Delete', metaKey: true }) },
])('$label selected notes and clears selection', ({ prop, trigger }) => {
const handler = vi.fn()
selectTwoNotes({ [prop]: handler })
trigger()
expect(handler).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
it('clear button on bulk action bar clears selection', () => {
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
selectTwoNotes()
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('bulk-clear-btn'))
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
})
it('Cmd+E archives selected notes when multiselect is active', () => {
const onBulkArchive = vi.fn()
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'e', metaKey: true })
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
it('Cmd+Backspace trashes selected notes when multiselect is active', () => {
const onBulkTrash = vi.fn()
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Backspace', metaKey: true })
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
it('Cmd+Delete trashes selected notes when multiselect is active', () => {
const onBulkTrash = vi.fn()
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.keyDown(window, { key: 'Delete', metaKey: true })
expect(onBulkTrash).toHaveBeenCalledWith([mockEntries[0].path, mockEntries[1].path])
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
})
it('no bulk action bar when nothing is selected', () => {
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
@@ -1360,17 +1249,6 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
})
describe('countByFilter', () => {
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
...overrides,
})
it('counts open, archived, and trashed notes per type', () => {
const entries = [
makeEntry({ path: '/1.md', isA: 'Project' }),
@@ -1397,17 +1275,6 @@ describe('countByFilter', () => {
})
describe('NoteList — filter pills', () => {
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
...overrides,
})
const projectEntries = [
makeEntry({ path: '/p1.md', title: 'Open Project 1', isA: 'Project' }),
makeEntry({ path: '/p2.md', title: 'Open Project 2', isA: 'Project' }),
@@ -1426,11 +1293,44 @@ describe('NoteList — filter pills', () => {
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
})
it('does not show filter pills in All Notes view', () => {
it('shows filter pills in All Notes view', () => {
render(
<NoteList {...defaultFilterProps} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('filter-pills')).not.toBeInTheDocument()
expect(screen.getByTestId('filter-pills')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-open')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-archived')).toBeInTheDocument()
expect(screen.getByTestId('filter-pill-trashed')).toBeInTheDocument()
})
it('shows correct All Notes count badges across all types', () => {
render(
<NoteList {...defaultFilterProps} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
// projectEntries: 2 open Projects + 1 open Note = 3 open, 1 archived, 1 trashed
const openPill = screen.getByTestId('filter-pill-open')
const archivedPill = screen.getByTestId('filter-pill-archived')
const trashedPill = screen.getByTestId('filter-pill-trashed')
expect(openPill).toHaveTextContent('3')
expect(archivedPill).toHaveTextContent('1')
expect(trashedPill).toHaveTextContent('1')
})
it('shows archived notes in All Notes when filter is archived', () => {
render(
<NoteList noteListFilter="archived" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Archived Project')).toBeInTheDocument()
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
expect(screen.queryByText('Some Note')).not.toBeInTheDocument()
})
it('shows trashed notes in All Notes when filter is trashed', () => {
render(
<NoteList noteListFilter="trashed" onNoteListFilterChange={noopFilterChange} entries={projectEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Trashed Project')).toBeInTheDocument()
expect(screen.queryByText('Open Project 1')).not.toBeInTheDocument()
})
it('shows correct count badges for each filter', () => {
@@ -1484,17 +1384,6 @@ describe('NoteList — filter pills', () => {
})
describe('NoteList — filterEntries with subFilter', () => {
const makeEntry = (overrides: Partial<VaultEntry>): VaultEntry => ({
path: '/test.md', filename: 'test.md', title: 'Test', isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
...overrides,
})
const entries = [
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
makeEntry({ path: '/2.md', title: 'Archived', isA: 'Project', archived: true }),
@@ -1521,4 +1410,33 @@ describe('NoteList — filterEntries with subFilter', () => {
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' })
expect(result.map(e => e.title)).toEqual(['Active'])
})
it('filters all notes by open sub-filter', () => {
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'open')
expect(result.map(e => e.title)).toEqual(['Active', 'Other'])
})
it('filters all notes by archived sub-filter', () => {
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'archived')
expect(result.map(e => e.title)).toEqual(['Archived'])
})
it('filters all notes by trashed sub-filter', () => {
const result = filterEntries(entries, { kind: 'filter', filter: 'all' }, 'trashed')
expect(result.map(e => e.title)).toEqual(['Trashed'])
})
})
describe('countAllByFilter', () => {
it('counts all entries by filter status', () => {
const entries = [
makeEntry({ path: '/1.md', isA: 'Project' }),
makeEntry({ path: '/2.md', isA: 'Note' }),
makeEntry({ path: '/3.md', isA: 'Project', archived: true }),
makeEntry({ path: '/4.md', isA: 'Note', trashed: true }),
makeEntry({ path: '/5.md', isA: 'Person', archived: true, trashed: true }),
]
const counts = countAllByFilter(entries)
expect(counts).toEqual({ open: 2, archived: 1, trashed: 2 })
})
})

View File

@@ -1,7 +1,7 @@
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import { countByFilter } from '../utils/noteListHelpers'
import { countByFilter, countAllByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
import { NoteItem } from './NoteItem'
import { prefetchNoteContent } from '../hooks/useTabManagement'
import { BulkActionBar } from './BulkActionBar'
@@ -9,6 +9,7 @@ import { useMultiSelect } from '../hooks/useMultiSelect'
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
import { NoteListHeader } from './note-list/NoteListHeader'
import { FilterPills } from './note-list/FilterPills'
import { InboxFilterPills } from './note-list/InboxFilterPills'
import { EntityView, ListView } from './note-list/NoteListViews'
import { DeletedNotesBanner } from './note-list/TrashWarningBanner'
import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
@@ -23,6 +24,8 @@ interface NoteListProps {
selectedNote: VaultEntry | null
noteListFilter: NoteListFilter
onNoteListFilterChange: (filter: NoteListFilter) => void
inboxPeriod?: InboxPeriod
onInboxPeriodChange?: (period: InboxPeriod) => void
modifiedFiles?: ModifiedFile[]
modifiedFilesError?: string | null
getNoteStatus?: (path: string) => NoteStatus
@@ -37,25 +40,34 @@ interface NoteListProps {
onEmptyTrash?: () => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
}
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const isSectionGroup = selection.kind === 'sectionGroup'
const subFilter = isSectionGroup ? noteListFilter : undefined
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
const showFilterPills = isSectionGroup || isAllNotesView
const subFilter = showFilterPills ? noteListFilter : undefined
const filterCounts = useMemo(
() => isSectionGroup ? countByFilter(entries, selection.type) : { open: 0, archived: 0, trashed: 0 },
[entries, isSectionGroup, selection],
() => isSectionGroup ? countByFilter(entries, selection.type) : isAllNotesView ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
[entries, isSectionGroup, isAllNotesView, selection],
)
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, onUpdateTypeSort, updateEntry })
const inboxCounts = useMemo(
() => isInboxView ? countInboxByPeriod(entries) : { week: 0, month: 0, quarter: 0, all: 0 },
[entries, isInboxView],
)
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry })
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const typeEntryMap = useTypeEntryMap(entries)
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter })
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined })
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const deletedCount = useMemo(
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
@@ -65,11 +77,11 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView })
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, multiSelect })
}, [onReplaceActiveTab, onSelectNote, multiSelect])
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
}, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect])
const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
@@ -90,13 +102,14 @@ 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} />
{isSectionGroup && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
{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="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} />
) : (
<ListView isTrashView={isTrashView} isArchivedView={isArchivedView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
<ListView isTrashView={isTrashView} isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}

View File

@@ -142,15 +142,6 @@ describe('RawEditorView', () => {
expect(cmScroller).toBeInTheDocument()
})
it('supports dark theme', () => {
render(<RawEditorView {...defaultProps} isDark />)
const container = screen.getByTestId('raw-editor-codemirror')
const cmEditor = container.querySelector('.cm-editor')
expect(cmEditor).toBeInTheDocument()
// CM applies dark theme via .cm-theme class — verify editor re-creates with isDark
expect(cmEditor?.querySelector('.cm-gutters')).toBeInTheDocument()
})
it('cleans up CodeMirror view on unmount', () => {
const { unmount } = render(<RawEditorView {...defaultProps} />)
const container = screen.getByTestId('raw-editor-codemirror')

View File

@@ -22,7 +22,6 @@ export interface RawEditorViewProps {
entries: VaultEntry[]
onContentChange: (path: string, content: string) => void
onSave: () => void
isDark?: boolean
/** Mutable ref updated on every keystroke with the latest doc string.
* Allows the parent to flush debounced content before unmount. */
latestContentRef?: React.MutableRefObject<string | null>
@@ -38,7 +37,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, isDark = false, latestContentRef }: RawEditorViewProps) {
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pathRef = useRef(path)
@@ -112,7 +111,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
return false
}, [autocomplete])
const viewRef = useCodeMirror(containerRef, content, isDark, {
const viewRef = useCodeMirror(containerRef, content, {
onDocChange: handleDocChange,
onCursorActivity: handleCursorActivity,
onSave: handleSave,

View File

@@ -125,7 +125,7 @@ describe('SearchPanel', () => {
expect(onClose).toHaveBeenCalled()
})
it('performs unified search with keyword then hybrid', async () => {
it('performs keyword search', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: '...designing APIs for AI...', score: 0.87, note_type: 'Essay' },
@@ -140,7 +140,6 @@ describe('SearchPanel', () => {
const input = screen.getByPlaceholderText('Search in all notes...')
fireEvent.change(input, { target: { value: 'api design' } })
// Should call keyword search first
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('search_vault', {
vaultPath: '/vault',
@@ -150,20 +149,9 @@ describe('SearchPanel', () => {
})
})
// Results should appear
await waitFor(() => {
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
})
// Should also call hybrid search
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('search_vault', {
vaultPath: '/vault',
query: 'api design',
mode: 'hybrid',
limit: 20,
})
})
})
it('shows no results message when search returns empty', async () => {
@@ -331,7 +319,7 @@ describe('SearchPanel', () => {
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'test' } })
// Spinner appears when keyword search starts (after debounce)
// Spinner appears when search starts (after debounce)
await waitFor(() => {
expect(screen.getByTestId('search-spinner')).toBeInTheDocument()
})
@@ -342,21 +330,9 @@ describe('SearchPanel', () => {
elapsed_ms: 30,
})
// Keyword results appear, spinner still visible (hybrid in progress)
// Spinner disappears after search completes
await waitFor(() => {
expect(screen.getByText('Result')).toBeInTheDocument()
expect(screen.getByTestId('search-spinner')).toBeInTheDocument()
})
// Wait for hybrid call then resolve it
await waitFor(() => { expect(resolvers).toHaveLength(2) })
resolvers[1]({
results: [{ title: 'Result', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
elapsed_ms: 150,
})
// Spinner disappears after hybrid completes
await waitFor(() => {
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
})
})
@@ -388,44 +364,12 @@ describe('SearchPanel', () => {
})
})
it('keeps keyword results when hybrid search fails', async () => {
mockInvokeFn.mockImplementation(async (_cmd: string, args?: Record<string, unknown>) => {
const mode = (args as Record<string, string>)?.mode
if (mode === 'keyword') {
return {
results: [{ title: 'Keyword Only', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
elapsed_ms: 30,
}
}
throw new Error('qmd unavailable')
})
render(
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
)
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'test' } })
// Keyword results appear
await waitFor(() => {
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
})
// Spinner disappears after hybrid fails
await waitFor(() => {
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
})
// Keyword results remain
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
})
it('deduplicates results when backend returns same note twice', async () => {
mockInvokeFn.mockResolvedValue({
results: [
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'keyword hit', score: 0.7, note_type: 'Essay' },
{ title: 'Refactoring Retreat', path: '/vault/event/retreat.md', snippet: 'unique', score: 0.6, note_type: 'Event' },
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'semantic hit', score: 0.9, note_type: 'Essay' },
{ title: 'How to Design AI-first APIs', path: '/vault/essay/ai-apis.md', snippet: 'duplicate hit', score: 0.9, note_type: 'Essay' },
],
elapsed_ms: 48,
})

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { SettingsPanel } from './SettingsPanel'
import type { Settings } from '../types'
import type { ThemeManager } from '../hooks/useThemeManager'
// Mock the tauri/mock-tauri calls used by GitHubSection
const mockInvokeFn = vi.fn()
@@ -36,16 +35,6 @@ const populatedSettings: Settings = {
auto_pull_interval_minutes: 5,
}
const mockThemeManager: ThemeManager = {
themes: [],
activeThemeId: null,
activeTheme: null,
isDark: false,
switchTheme: vi.fn(),
createTheme: vi.fn().mockResolvedValue('untitled'),
reloadThemes: vi.fn(),
}
describe('SettingsPanel', () => {
const onSave = vi.fn()
const onClose = vi.fn()
@@ -56,14 +45,14 @@ describe('SettingsPanel', () => {
it('renders nothing when not open', () => {
const { container } = render(
<SettingsPanel open={false} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={false} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(container.innerHTML).toBe('')
})
it('renders modal when open', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByText('AI Provider Keys')).toBeInTheDocument()
@@ -72,7 +61,7 @@ describe('SettingsPanel', () => {
it('shows two key fields with labels', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByText('OpenAI')).toBeInTheDocument()
expect(screen.getByText('Google AI')).toBeInTheDocument()
@@ -80,7 +69,7 @@ describe('SettingsPanel', () => {
it('populates fields from settings', () => {
render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
)
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
const googleInput = screen.getByTestId('settings-key-google-ai') as HTMLInputElement
@@ -91,7 +80,7 @@ describe('SettingsPanel', () => {
it('calls onSave with trimmed keys on save', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
const openaiInput = screen.getByTestId('settings-key-openai')
fireEvent.change(openaiInput, { target: { value: ' sk-openai-test ' } })
@@ -111,7 +100,7 @@ describe('SettingsPanel', () => {
it('converts empty/whitespace keys to null', () => {
render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
)
// Clear the openai key field
const openaiInput = screen.getByTestId('settings-key-openai')
@@ -131,7 +120,7 @@ describe('SettingsPanel', () => {
it('calls onClose when Cancel is clicked', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalled()
@@ -139,7 +128,7 @@ describe('SettingsPanel', () => {
it('calls onClose when close button is clicked', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTitle('Close settings'))
expect(onClose).toHaveBeenCalled()
@@ -147,7 +136,7 @@ describe('SettingsPanel', () => {
it('calls onClose on Escape key', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.keyDown(screen.getByTestId('settings-panel'), { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
@@ -155,7 +144,7 @@ describe('SettingsPanel', () => {
it('saves on Cmd+Enter', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
const openaiInput = screen.getByTestId('settings-key-openai')
fireEvent.change(openaiInput, { target: { value: 'sk-openai-test' } })
@@ -173,7 +162,7 @@ describe('SettingsPanel', () => {
it('calls onClose when clicking backdrop', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('settings-panel'))
expect(onClose).toHaveBeenCalled()
@@ -181,7 +170,7 @@ describe('SettingsPanel', () => {
it('clears a key field when X button is clicked', () => {
render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
)
const clearBtn = screen.getByTestId('clear-openai')
fireEvent.click(clearBtn)
@@ -192,14 +181,14 @@ describe('SettingsPanel', () => {
it('shows keyboard shortcut hint in footer', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByText(/to open settings/)).toBeInTheDocument()
})
it('resets fields when reopened with different settings', () => {
const { rerender } = render(
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={populatedSettings} onSave={onSave} onClose={onClose} />
)
// Verify initial state
const openaiInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
@@ -207,11 +196,11 @@ describe('SettingsPanel', () => {
// Close and reopen with different settings
rerender(
<SettingsPanel open={false} settings={populatedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={false} settings={populatedSettings} onSave={onSave} onClose={onClose} />
)
const newSettings: Settings = { ...emptySettings, openai_key: 'new-key' }
rerender(
<SettingsPanel open={true} settings={newSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={newSettings} onSave={onSave} onClose={onClose} />
)
const updatedInput = screen.getByTestId('settings-key-openai') as HTMLInputElement
expect(updatedInput.value).toBe('new-key')
@@ -220,7 +209,7 @@ describe('SettingsPanel', () => {
describe('GitHub OAuth section', () => {
it('shows Login with GitHub button when not connected', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByTestId('github-login')).toBeInTheDocument()
expect(screen.getByText('Login with GitHub')).toBeInTheDocument()
@@ -228,7 +217,7 @@ describe('SettingsPanel', () => {
it('does not show GitHub token input field', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.queryByTestId('settings-key-github-token')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('ghp_... or gho_...')).not.toBeInTheDocument()
@@ -241,7 +230,7 @@ describe('SettingsPanel', () => {
github_username: 'lucaong',
}
render(
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByTestId('github-connected')).toBeInTheDocument()
expect(screen.getByText('lucaong')).toBeInTheDocument()
@@ -256,7 +245,7 @@ describe('SettingsPanel', () => {
github_username: 'lucaong',
}
render(
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={connectedSettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('github-disconnect'))
@@ -285,7 +274,7 @@ describe('SettingsPanel', () => {
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('github-login'))
@@ -316,7 +305,7 @@ describe('SettingsPanel', () => {
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('github-login'))
@@ -337,7 +326,7 @@ describe('SettingsPanel', () => {
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('github-login'))
@@ -350,7 +339,7 @@ describe('SettingsPanel', () => {
it('shows GitHub section description about connecting', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByText(/Connect your GitHub account/)).toBeInTheDocument()
})
@@ -365,7 +354,7 @@ describe('SettingsPanel', () => {
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByTestId('github-login'))
@@ -387,7 +376,7 @@ describe('SettingsPanel', () => {
})
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} themeManager={mockThemeManager} />
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
const loginBtn = screen.getByTestId('github-login') as HTMLButtonElement

View File

@@ -1,16 +1,13 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut, Check, Plus } from '@phosphor-icons/react'
import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
import { ThemePropertyEditor } from './ThemePropertyEditor'
import type { Settings, ThemeFile } from '../types'
import type { ThemeManager } from '../hooks/useThemeManager'
import type { Settings } from '../types'
interface SettingsPanelProps {
open: boolean
settings: Settings
onSave: (settings: Settings) => void
onClose: () => void
themeManager: ThemeManager
}
@@ -116,12 +113,12 @@ function GitHubConnectedRow({ username, onDisconnect }: { username: string; onDi
// --- Settings Panel ---
export function SettingsPanel({ open, settings, onSave, onClose, themeManager }: SettingsPanelProps) {
export function SettingsPanel({ open, settings, onSave, onClose }: SettingsPanelProps) {
if (!open) return null
return <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} themeManager={themeManager} />
return <SettingsPanelInner settings={settings} onSave={onSave} onClose={onClose} />
}
function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit<SettingsPanelProps, 'open'>) {
function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelProps, 'open'>) {
const [openaiKey, setOpenaiKey] = useState(settings.openai_key ?? '')
const [googleKey, setGoogleKey] = useState(settings.google_key ?? '')
const [githubToken, setGithubToken] = useState(settings.github_token)
@@ -195,7 +192,6 @@ function SettingsPanelInner({ settings, onSave, onClose, themeManager }: Omit<Se
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
pullInterval={pullInterval} setPullInterval={setPullInterval}
themeManager={themeManager}
/>
<SettingsFooter onClose={onClose} onSave={handleSave} />
</div>
@@ -228,7 +224,6 @@ interface SettingsBodyProps {
onGitHubConnected: (token: string, username: string) => void
onGitHubDisconnect: () => void
pullInterval: number; setPullInterval: (v: number) => void
themeManager: ThemeManager
}
function SettingsBody(props: SettingsBodyProps) {
@@ -286,88 +281,10 @@ function SettingsBody(props: SettingsBodyProps) {
<option value={30}>30</option>
</select>
</div>
<div style={{ height: 1, background: 'var(--border)' }} />
<AppearanceSection themeManager={props.themeManager} />
</div>
)
}
// --- Appearance Section ---
function ColorSwatch({ color }: { color: string }) {
return (
<div
style={{ width: 14, height: 14, borderRadius: 3, background: color, border: '1px solid var(--border)', flexShrink: 0 }}
/>
)
}
function ThemeCard({ theme, active, onSelect }: { theme: ThemeFile; active: boolean; onSelect: () => void }) {
const swatchColors = ['background', 'foreground', 'primary', 'border', 'muted']
return (
<button
className="border rounded cursor-pointer text-left"
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', width: '100%',
background: active ? 'var(--accent)' : 'transparent',
borderColor: active ? 'var(--primary)' : 'var(--border)',
}}
onClick={onSelect}
type="button"
data-testid={`theme-card-${theme.id}`}
>
<div style={{ display: 'flex', gap: 3 }}>
{swatchColors.map(key => theme.colors[key] && <ColorSwatch key={key} color={theme.colors[key]} />)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--foreground)' }}>{theme.name}</div>
{theme.description && (
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{theme.description}</div>
)}
</div>
{active && <Check size={14} weight="bold" style={{ color: 'var(--primary)', flexShrink: 0 }} />}
</button>
)
}
function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) {
const { themes, activeThemeId, switchTheme, createTheme } = themeManager
return (
<>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Appearance</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
Choose a theme for your vault. Themes are stored in <code>_themes/</code> and synced with Git.
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }} data-testid="theme-list">
{themes.map(theme => (
<ThemeCard key={theme.id} theme={theme} active={theme.id === activeThemeId} onSelect={() => switchTheme(theme.id)} />
))}
</div>
<button
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground hover:border-foreground"
style={{ fontSize: 12, padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 4, alignSelf: 'flex-start' }}
onClick={() => createTheme()}
type="button"
data-testid="create-theme"
>
<Plus size={14} />
New Theme
</button>
{activeThemeId && (
<>
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
<ThemePropertyEditor themeManager={themeManager} />
</>
)}
</>
)
}
function SettingsFooter({ onClose, onSave }: { onClose: () => void; onSave: () => void }) {
return (
<div

View File

@@ -418,6 +418,36 @@ describe('Sidebar', () => {
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'changes' })
})
describe('Changes and Pulse in secondary bottom area', () => {
it('renders Changes outside the main top nav section', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
const changesEl = screen.getByText('Changes')
// Changes should be inside the secondary bottom area, not the top nav
const secondaryArea = changesEl.closest('[data-testid="sidebar-secondary"]')
expect(secondaryArea).not.toBeNull()
})
it('renders Pulse outside the main top nav section', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} isGitVault />)
const pulseEl = screen.getByText('Pulse')
const secondaryArea = pulseEl.closest('[data-testid="sidebar-secondary"]')
expect(secondaryArea).not.toBeNull()
})
it('does not render Changes or Pulse inside the top nav section', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
const topNav = screen.getByTestId('sidebar-top-nav')
expect(topNav.textContent).not.toContain('Changes')
expect(topNav.textContent).not.toContain('Pulse')
})
it('shows Changes badge count in secondary area', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={7} isGitVault />)
const secondaryArea = screen.getByTestId('sidebar-secondary')
expect(secondaryArea.textContent).toContain('7')
})
})
describe('dynamic custom type sections', () => {
const entriesWithCustomTypes: VaultEntry[] = [
...mockEntries,
@@ -1008,4 +1038,69 @@ describe('Sidebar', () => {
const mondaySections = screen.getAllByText(/Monday Ideas/i)
expect(mondaySections).toHaveLength(1)
})
it('renders Inbox as the first item in the top nav', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={5} />)
const topNav = screen.getByTestId('sidebar-top-nav')
const items = topNav.children
expect(items[0].textContent).toContain('Inbox')
expect(items[1].textContent).toContain('All Notes')
})
it('displays inbox count badge', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={12} />)
expect(screen.getByText('12')).toBeInTheDocument()
})
it('calls onSelect with inbox filter when clicking Inbox', () => {
const onSelect = vi.fn()
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={onSelect} inboxCount={3} />)
fireEvent.click(screen.getByText('Inbox'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
})
describe('emoji icon in sidebar section children', () => {
const entriesWithEmoji: VaultEntry[] = [
{
path: '/vault/project.md', filename: 'project.md', title: 'Project', isA: 'Type',
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
icon: 'rocket-launch', color: 'purple', order: null, sidebarLabel: null, template: null,
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
},
{
path: '/vault/project/build-app.md', filename: 'build-app.md', title: 'Build App',
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
createdAt: null, fileSize: 300, snippet: '', wordCount: 0, relationships: {},
icon: '🚀', color: null, order: null, sidebarLabel: null, template: null,
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
},
{
path: '/vault/project/no-icon.md', filename: 'no-icon.md', title: 'No Icon Project',
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null, template: null,
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
},
]
it('shows emoji icon before title in expanded section child', () => {
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByLabelText('Expand Projects'))
const buildApp = screen.getByText('Build App')
const parent = buildApp.closest('div')!
expect(parent.textContent).toBe('🚀Build App')
})
it('does not show emoji for notes without icon', () => {
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByLabelText('Expand Projects'))
const noIcon = screen.getByText('No Icon Project')
const parent = noIcon.closest('div')!
expect(parent.textContent).toBe('No Icon Project')
})
})
})

View File

@@ -12,7 +12,7 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse,
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse, Tray,
} from '@phosphor-icons/react'
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
import {
@@ -34,6 +34,7 @@ interface SidebarProps {
onRenameSection?: (typeName: string, label: string) => void
onToggleTypeVisibility?: (typeName: string) => void
modifiedCount?: number
inboxCount?: number
onCommitPush?: () => void
onCollapse?: () => void
isGitVault?: boolean
@@ -222,7 +223,7 @@ export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
onToggleTypeVisibility,
modifiedCount = 0, onCommitPush, onCollapse, isGitVault = false,
modifiedCount = 0, inboxCount = 0, onCommitPush, onCollapse, isGitVault = false,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -304,14 +305,11 @@ export const Sidebar = memo(function Sidebar({
<SidebarTitleBar onCollapse={onCollapse} />
<nav className="flex-1 overflow-y-auto">
{/* Top nav */}
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
<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' })} />
{modifiedCount > 0 && (
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
)}
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} />
</div>
{/* Sections header + visibility popover */}
@@ -335,6 +333,13 @@ export const Sidebar = memo(function Sidebar({
</DndContext>
</nav>
{/* Secondary area: Changes + Pulse */}
<div className="shrink-0 border-t border-border" data-testid="sidebar-secondary" style={{ padding: '4px 6px' }}>
{modifiedCount > 0 && (
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} compact />
)}
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} compact />
</div>
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />

View File

@@ -1,6 +1,7 @@
import { type ComponentType, useState, useEffect, useRef } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { isEmoji } from '../utils/emoji'
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
@@ -25,7 +26,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 }: {
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled, disabledTooltip, compact }: {
icon: ComponentType<IconProps>
label: string
count?: number
@@ -36,25 +37,30 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
onClick?: () => void
disabled?: boolean
disabledTooltip?: string
compact?: boolean
}) {
const iconSize = compact ? 14 : 16
const textClass = compact ? 'text-[12px]' : 'text-[13px]'
const padding = compact ? '4px 16px' : '6px 16px'
if (disabled) {
return (
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
<Icon size={16} />
<span className="flex-1 text-[13px] font-medium">{label}</span>
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding, borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
<Icon size={iconSize} />
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
</div>
)
}
return (
<div
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
style={{ padding: '6px 16px', borderRadius: 4 }}
style={{ padding, borderRadius: 4 }}
onClick={onClick}
>
<Icon size={16} />
<span className="flex-1 text-[13px] font-medium">{label}</span>
<Icon size={iconSize} />
<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: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...badgeStyle }}>
<span className={cn("flex items-center justify-center", badgeClassName)} style={{ height: compact ? 18 : 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...badgeStyle }}>
{count}
</span>
)}
@@ -143,7 +149,7 @@ function SectionChildList({ items, selection, sectionColor, sectionLightColor, o
const active = isSelectionActive(selection, sel)
return (
<SectionChildItem
key={entry.path} title={entry.title} isActive={active}
key={entry.path} title={entry.title} icon={entry.icon} isActive={active}
sectionColor={active ? sectionColor : undefined}
sectionLightColor={active ? sectionLightColor : undefined}
onClick={() => { onSelect(sel); onSelectNote?.(entry) }}
@@ -232,8 +238,8 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
)
}
function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, onClick }: {
title: string; isActive: boolean
function SectionChildItem({ title, icon, isActive, sectionColor, sectionLightColor, onClick }: {
title: string; icon?: string | null; isActive: boolean
sectionColor?: string; sectionLightColor?: string
onClick: () => void
}) {
@@ -243,7 +249,7 @@ function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, on
style={{ padding: '4px 16px 4px 28px', ...(isActive && { backgroundColor: sectionLightColor, color: sectionColor }) }}
onClick={onClick}
>
{title}
{icon && isEmoji(icon) && <span className="mr-1">{icon}</span>}{title}
</div>
)
}

View File

@@ -23,13 +23,12 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
}
/** Single BlockNote editor view — content is swapped via replaceBlocks */
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme, editable = true }: {
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, editable = true }: {
editor: ReturnType<typeof useCreateBlockNote>
entries: VaultEntry[]
onNavigateWikilink: (target: string) => void
onChange?: () => void
vaultPath?: string
isDarkTheme?: boolean
editable?: boolean
}) {
const navigateRef = useRef(onNavigateWikilink)
@@ -113,7 +112,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
)}
<BlockNoteView
editor={editor}
theme={isDarkTheme ? 'dark' : 'light'}
theme="light"
onChange={onChange}
editable={editable}
>

View File

@@ -2,8 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { StatusBar } from './StatusBar'
import type { VaultOption } from './StatusBar'
import { formatIndexedElapsed } from '../utils/indexingHelpers'
vi.mock('../utils/url', async () => {
const actual = await vi.importActual('../utils/url')
return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) }
@@ -226,121 +224,6 @@ describe('StatusBar', () => {
expect(screen.getByTitle('View pending changes')).toBeInTheDocument()
})
it('shows indexing badge when indexing is in progress', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'scanning', current: 342, total: 1057, done: false, error: null }}
/>
)
expect(screen.getByTestId('status-indexing')).toBeInTheDocument()
expect(screen.getByText(/Indexing… 342\/1,057/)).toBeInTheDocument()
})
it('shows embedding phase in indexing badge', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'embedding', current: 50, total: 200, done: false, error: null }}
/>
)
expect(screen.getByText(/Embedding… 50\/200/)).toBeInTheDocument()
})
it('shows index ready when indexing is complete', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'complete', current: 1057, total: 1057, done: true, error: null }}
/>
)
expect(screen.getByText('Index ready')).toBeInTheDocument()
})
it('shows error state in indexing badge with retry label', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
/>
)
expect(screen.getByText('Index failed — retry')).toBeInTheDocument()
})
it('hides indexing badge when phase is unavailable', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'unavailable', current: 0, total: 0, done: true, error: 'qmd not available' }}
/>
)
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
})
it('calls onRetryIndexing when clicking error badge', () => {
const onRetryIndexing = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
onRetryIndexing={onRetryIndexing}
/>
)
fireEvent.click(screen.getByTestId('status-indexing'))
expect(onRetryIndexing).toHaveBeenCalledOnce()
})
it('hides indexing badge when phase is idle', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
/>
)
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
})
it('hides indexing badge when no progress prop provided', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
)
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
})
it('shows installing phase in indexing badge', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'installing', current: 0, total: 0, done: false, error: null }}
/>
)
expect(screen.getByText('Installing search…')).toBeInTheDocument()
})
it('shows MCP warning badge when status is not_installed', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" />
@@ -396,70 +279,38 @@ describe('StatusBar', () => {
expect(onInstallMcp).not.toHaveBeenCalled()
})
it('shows "Indexed just now" when lastIndexedTime is recent and phase is idle', () => {
it('shows Pull required label when syncStatus is pull_required', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
lastIndexedTime={Date.now() - 5000}
/>
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} syncStatus="pull_required" />
)
expect(screen.getByText(/Indexed just now/)).toBeInTheDocument()
expect(screen.getByTestId('status-indexed-time')).toBeInTheDocument()
expect(screen.getByText('Pull required')).toBeInTheDocument()
})
it('calls onReindexVault when clicking the indexed time badge', () => {
const onReindexVault = vi.fn()
it('calls onPullAndPush when clicking Pull required badge', () => {
const onPullAndPush = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
lastIndexedTime={Date.now() - 5000}
onReindexVault={onReindexVault}
/>
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} syncStatus="pull_required" onPullAndPush={onPullAndPush} />
)
fireEvent.click(screen.getByTestId('status-indexed-time'))
expect(onReindexVault).toHaveBeenCalledOnce()
fireEvent.click(screen.getByTestId('status-sync'))
expect(onPullAndPush).toHaveBeenCalledOnce()
})
it('hides indexed time badge when no lastIndexedTime', () => {
it('shows git status popup when clicking idle sync badge', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
syncStatus="idle"
remoteStatus={{ branch: 'main', ahead: 2, behind: 1, hasRemote: true }}
/>
)
expect(screen.queryByTestId('status-indexed-time')).not.toBeInTheDocument()
})
})
describe('formatIndexedElapsed', () => {
it('returns empty string for null', () => {
expect(formatIndexedElapsed(null)).toBe('')
})
it('returns "Indexed just now" for < 60s', () => {
expect(formatIndexedElapsed(Date.now() - 30_000)).toBe('Indexed just now')
})
it('returns minutes for < 60min', () => {
expect(formatIndexedElapsed(Date.now() - 5 * 60_000)).toBe('Indexed 5m ago')
})
it('returns hours for < 24h', () => {
expect(formatIndexedElapsed(Date.now() - 3 * 3600_000)).toBe('Indexed 3h ago')
})
it('returns days for >= 24h', () => {
expect(formatIndexedElapsed(Date.now() - 48 * 3600_000)).toBe('Indexed 2d ago')
fireEvent.click(screen.getByTestId('status-sync'))
expect(screen.getByTestId('git-status-popup')).toBeInTheDocument()
expect(screen.getByText('main')).toBeInTheDocument()
expect(screen.getByText(/2 ahead/)).toBeInTheDocument()
expect(screen.getByText(/1 behind/)).toBeInTheDocument()
})
})

View File

@@ -1,10 +1,8 @@
import { useState, useRef, useEffect } from 'react'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X, Cpu } from 'lucide-react'
import type { LastCommitInfo, SyncStatus } from '../types'
import type { IndexingProgress } from '../hooks/useIndexing'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
import type { McpStatus } from '../hooks/useMcpStatus'
import { openExternalUrl } from '../utils/url'
import { formatIndexedElapsed } from '../utils/indexingHelpers'
export interface VaultOption {
label: string
@@ -27,16 +25,14 @@ interface StatusBarProps {
lastSyncTime?: number | null
conflictCount?: number
lastCommitInfo?: LastCommitInfo | null
remoteStatus?: GitRemoteStatus | null
onTriggerSync?: () => void
onPullAndPush?: () => void
onOpenConflictResolver?: () => void
zoomLevel?: number
onZoomReset?: () => void
buildNumber?: string
onCheckForUpdates?: () => void
indexingProgress?: IndexingProgress
lastIndexedTime?: number | null
onRetryIndexing?: () => void
onReindexVault?: () => void
onRemoveVault?: (path: string) => void
mcpStatus?: McpStatus
onInstallMcp?: () => void
@@ -159,10 +155,10 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConn
const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const
const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const
const SEP_STYLE = { color: 'var(--border)' } as const
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle, pull_required: ArrowDown }
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' }
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' }
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed', pull_required: 'Pull required' }
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)', pull_required: 'var(--accent-orange)' }
function formatElapsedSync(lastSyncTime: number | null): string {
if (!lastSyncTime) return 'Not synced'
@@ -201,21 +197,114 @@ function CommitBadge({ info }: { info: LastCommitInfo }) {
)
}
function SyncBadge({ status, lastSyncTime, onTriggerSync, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void; onOpenConflictResolver?: () => void }) {
function syncBadgeTitle(status: SyncStatus): string {
if (status === 'conflict') return 'Click to resolve conflicts'
if (status === 'syncing') return 'Syncing…'
if (status === 'pull_required') return 'Click to pull from remote and push'
return 'Click to sync now'
}
function SyncBadge({ status, lastSyncTime, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; remoteStatus?: GitRemoteStatus | null; onTriggerSync?: () => void; onPullAndPush?: () => void; onOpenConflictResolver?: () => void }) {
const [showPopup, setShowPopup] = useState(false)
const popupRef = useRef<HTMLDivElement>(null)
const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw
const isSyncing = status === 'syncing'
const isConflict = status === 'conflict'
const handleClick = isConflict ? onOpenConflictResolver : onTriggerSync
const isPullRequired = status === 'pull_required'
const handleClick = () => {
if (isConflict) { onOpenConflictResolver?.(); return }
if (isPullRequired) { onPullAndPush?.(); return }
setShowPopup(v => !v)
}
useEffect(() => {
if (!showPopup) return
const handleOutside = (e: MouseEvent) => {
if (popupRef.current && !popupRef.current.contains(e.target as Node)) setShowPopup(false)
}
document.addEventListener('mousedown', handleOutside)
return () => document.removeEventListener('mousedown', handleOutside)
}, [showPopup])
return (
<span
role="button"
onClick={handleClick}
style={{ ...ICON_STYLE, cursor: handleClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
title={isConflict ? 'Click to resolve conflicts' : isSyncing ? 'Syncing…' : 'Click to sync now'}
data-testid="status-sync"
<div ref={popupRef} style={{ position: 'relative' }}>
<span
role="button"
onClick={handleClick}
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3 }}
title={syncBadgeTitle(status)}
data-testid="status-sync"
>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
</span>
{showPopup && (
<GitStatusPopup
status={status}
remoteStatus={remoteStatus ?? null}
onPull={onTriggerSync}
onClose={() => setShowPopup(false)}
/>
)}
</div>
)
}
function GitStatusPopup({ status, remoteStatus, onPull, onClose }: { status: SyncStatus; remoteStatus: GitRemoteStatus | null; onPull?: () => void; onClose: () => void }) {
const branch = remoteStatus?.branch || '—'
const ahead = remoteStatus?.ahead ?? 0
const behind = remoteStatus?.behind ?? 0
const hasRemote = remoteStatus?.hasRemote ?? false
return (
<div
data-testid="git-status-popup"
style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
background: 'var(--sidebar)', border: '1px solid var(--border)',
borderRadius: 6, padding: 8, minWidth: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
zIndex: 1000, fontSize: 12, color: 'var(--foreground)',
}}
>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<GitBranch size={13} style={{ color: 'var(--muted-foreground)' }} />
<span style={{ fontWeight: 500 }}>{branch}</span>
</div>
{hasRemote && (
<div style={{ display: 'flex', gap: 12, marginBottom: 6, color: 'var(--muted-foreground)' }}>
{ahead > 0 && <span title={`${ahead} commit${ahead > 1 ? 's' : ''} ahead of remote`}> {ahead} ahead</span>}
{behind > 0 && <span title={`${behind} commit${behind > 1 ? 's' : ''} behind remote`} style={{ color: 'var(--accent-orange)' }}> {behind} behind</span>}
{ahead === 0 && behind === 0 && <span>In sync with remote</span>}
</div>
)}
{!hasRemote && (
<div style={{ color: 'var(--muted-foreground)', marginBottom: 6 }}>No remote configured</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4, color: 'var(--muted-foreground)' }}>
Status: {status === 'idle' ? 'Synced' : status === 'pull_required' ? 'Pull required' : status === 'conflict' ? 'Conflicts' : status === 'error' ? 'Error' : status === 'syncing' ? 'Syncing…' : status}
</div>
{hasRemote && (
<div style={{ display: 'flex', gap: 4, marginTop: 6, borderTop: '1px solid var(--border)', paddingTop: 6 }}>
<button
onClick={() => { onPull?.(); onClose() }}
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '3px 8px',
background: 'transparent', border: '1px solid var(--border)', borderRadius: 4,
fontSize: 11, color: 'var(--foreground)', cursor: 'pointer',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
data-testid="git-status-pull-btn"
>
<ArrowDown size={11} />Pull
</button>
</div>
)}
</div>
)
}
@@ -239,70 +328,6 @@ function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void
)
}
const INDEXING_LABELS: Record<string, string> = {
installing: 'Installing search…',
scanning: 'Indexing…',
embedding: 'Embedding…',
complete: 'Index ready',
error: 'Index failed — retry',
unavailable: 'Search unavailable',
}
function IndexingBadge({ progress, lastIndexedTime, onRetry, onReindex }: { progress: IndexingProgress; lastIndexedTime?: number | null; onRetry?: () => void; onReindex?: () => void }) {
const isIdle = progress.phase === 'idle' || progress.phase === 'unavailable'
// When idle, show "Indexed Xm ago" if we have a timestamp
if (isIdle) {
if (!lastIndexedTime) return null
const elapsed = formatIndexedElapsed(lastIndexedTime)
if (!elapsed) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={onReindex ? 'button' : undefined}
onClick={onReindex}
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', cursor: onReindex ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title={onReindex ? 'Click to reindex vault' : undefined}
data-testid="status-indexed-time"
onMouseEnter={onReindex ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onReindex ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
>
<Search size={13} />{elapsed}
</span>
</>
)
}
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
const isActive = !progress.done
const isError = progress.phase === 'error'
const showCount = progress.total > 0 && isActive
const displayText = showCount
? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}`
: label
const color = isError ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={isError && onRetry ? 'button' : undefined}
onClick={isError && onRetry ? onRetry : undefined}
style={{ ...ICON_STYLE, color, cursor: isError && onRetry ? 'pointer' : 'default' }}
title={isError ? 'Click to retry indexing' : undefined}
data-testid="status-indexing"
>
{isActive
? <Loader2 size={13} className="animate-spin" />
: <Search size={13} />
}
{displayText}
</span>
</>
)
}
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
if (count <= 0) return null
return (
@@ -356,7 +381,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -378,11 +403,10 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
onMouseLeave={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
><Package size={13} />{buildNumber ?? 'b?'}</span>
<span style={SEP_STYLE}>|</span>
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} onOpenConflictResolver={onOpenConflictResolver} />
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} remoteStatus={remoteStatus} onTriggerSync={onTriggerSync} onPullAndPush={onPullAndPush} onOpenConflictResolver={onOpenConflictResolver} />
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
{indexingProgress && <IndexingBadge progress={indexingProgress} lastIndexedTime={lastIndexedTime} onRetry={onRetryIndexing} onReindex={onReindexVault} />}
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>

View File

@@ -182,7 +182,7 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
}
const STATUS_DOT: Record<string, { color: string; testId: string; title: string; pulse?: boolean }> = {
unsaved: { color: 'var(--accent-blue, #3b82f6)', testId: 'tab-unsaved-indicator', title: 'Unsaved — Cmd+S to save' },
unsaved: { color: 'var(--accent-blue, #3b82f6)', testId: 'tab-unsaved-indicator', title: 'Auto-saving…', pulse: true },
pendingSave: { color: 'var(--accent-green)', testId: 'tab-pending-save-indicator', title: 'Saving to disk…', pulse: true },
new: { color: 'var(--accent-green)', testId: 'tab-new-indicator', title: 'New (uncommitted)' },
modified: { color: 'var(--accent-orange)', testId: 'tab-modified-indicator', title: 'Modified (uncommitted)' },
@@ -242,7 +242,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
{isEditing ? (
<InlineTabEdit initialValue={tab.entry.title} onSave={onRenameSave} onCancel={onRenameCancel} />
) : (
<span className="truncate" style={noteStatus === 'unsaved' ? { fontStyle: 'italic' } : undefined} onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
<span className="truncate" onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
{tab.entry.icon && isEmoji(tab.entry.icon) && <span className="mr-1">{tab.entry.icon}</span>}
{tab.entry.title}
</span>

View File

@@ -1,133 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { ThemePropertyEditor } from './ThemePropertyEditor'
import type { ThemeManager } from '../hooks/useThemeManager'
function makeThemeManager(overrides: Partial<ThemeManager> = {}): ThemeManager {
return {
themes: [],
activeThemeId: '/vault/_themes/My Theme.md',
activeTheme: { id: '/vault/_themes/My Theme.md', name: 'My Theme', description: '', colors: {}, typography: {}, spacing: {} },
activeThemeContent: '---\ntype: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
isDark: false,
switchTheme: vi.fn(),
createTheme: vi.fn().mockResolvedValue(''),
reloadThemes: vi.fn(),
updateThemeProperty: vi.fn(),
...overrides,
}
}
describe('ThemePropertyEditor', () => {
it('shows message when no theme is active', () => {
const tm = makeThemeManager({ activeThemeId: null })
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByText(/Select a theme/)).toBeInTheDocument()
})
it('renders the editor when a theme is active', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByTestId('theme-property-editor')).toBeInTheDocument()
})
it('shows section headers for all theme.json sections', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByText('Typography')).toBeInTheDocument()
expect(screen.getByText('Headings')).toBeInTheDocument()
expect(screen.getByText('Lists')).toBeInTheDocument()
expect(screen.getByText('Code Blocks')).toBeInTheDocument()
expect(screen.getByText('Blockquote')).toBeInTheDocument()
expect(screen.getByText('Table')).toBeInTheDocument()
expect(screen.getByText('Horizontal Rule')).toBeInTheDocument()
expect(screen.getByText('Colors')).toBeInTheDocument()
})
it('shows active theme name', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByText('My Theme')).toBeInTheDocument()
})
it('expands Typography section by default', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// Typography section should be expanded, showing its properties
expect(screen.getByTestId('theme-input-editor-font-size')).toBeInTheDocument()
})
it('shows current theme value for overridden properties', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
const fontSizeInput = screen.getByTestId('theme-input-editor-font-size') as HTMLInputElement
expect(fontSizeInput.value).toBe('18')
})
it('shows default value for non-overridden properties', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// editor-max-width is not in the theme content, so it should show the default (720)
const maxWidthInput = screen.getByTestId('theme-input-editor-max-width') as HTMLInputElement
expect(maxWidthInput.value).toBe('720')
})
it('calls updateThemeProperty on number input change', async () => {
vi.useFakeTimers()
const updateFn = vi.fn()
const tm = makeThemeManager({ updateThemeProperty: updateFn })
render(<ThemePropertyEditor themeManager={tm} />)
const fontSizeInput = screen.getByTestId('theme-input-editor-font-size') as HTMLInputElement
fireEvent.change(fontSizeInput, { target: { value: '16' } })
// Debounce fires after 300ms
act(() => { vi.advanceTimersByTime(300) })
expect(updateFn).toHaveBeenCalledWith('editor-font-size', '16px')
vi.useRealTimers()
})
it('expands collapsed sections on click', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// Lists section should be collapsed by default
expect(screen.queryByTestId('theme-input-lists-bullet-size')).not.toBeInTheDocument()
// Click to expand
fireEvent.click(screen.getByTestId('theme-section-lists-toggle'))
expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
})
it('toggles section via keyboard Enter', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
const toggle = screen.getByTestId('theme-section-lists-toggle')
fireEvent.keyDown(toggle, { key: 'Enter' })
expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
})
it('toggles section via keyboard Space', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
const toggle = screen.getByTestId('theme-section-lists-toggle')
fireEvent.keyDown(toggle, { key: ' ' })
expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
})
it('shows heading subsections after expanding Headings', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
fireEvent.click(screen.getByTestId('theme-section-headings-toggle'))
expect(screen.getByText('Heading 1')).toBeInTheDocument()
expect(screen.getByText('Heading 2')).toBeInTheDocument()
expect(screen.getByText('Heading 3')).toBeInTheDocument()
expect(screen.getByText('Heading 4')).toBeInTheDocument()
})
it('shows unit label for numeric properties', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// "px" should appear near Font Size input
const container = screen.getByTestId('theme-input-editor-font-size').parentElement!
expect(container.textContent).toContain('px')
})
})

View File

@@ -1,321 +0,0 @@
import { useState, useCallback, useMemo, useRef } from 'react'
import { CaretRight } from '@phosphor-icons/react'
import { ColorSwatch } from './ColorInput'
import { getThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from '../utils/themeSchema'
import type { ThemeProperty, ThemeSection, ThemeSubsection } from '../utils/themeSchema'
import type { ThemeManager } from '../hooks/useThemeManager'
import { parseFrontmatter } from '../utils/frontmatter'
import { isValidCssColor } from '../utils/colorUtils'
/** Extract current theme property values from frontmatter content. */
function useThemeValues(content: string | undefined): Record<string, string> {
return useMemo(() => {
if (!content) return {}
const fm = parseFrontmatter(content)
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(fm)) {
if (typeof value === 'string') result[key] = value
else if (typeof value === 'number') result[key] = String(value)
else if (typeof value === 'boolean') result[key] = String(value)
}
return result
}, [content])
}
// --- Individual input components ---
function NumberInput({ property, value, onChange }: {
property: ThemeProperty
value: string | number
onChange: (val: string) => void
}) {
const numericValue = typeof value === 'number' ? value : parseFloat(String(value)) || 0
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
if (raw === '' || raw === '-') return
const num = parseFloat(raw)
if (isNaN(num)) return
if (property.min !== undefined && num < property.min) return
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
onChange(formatValueForFrontmatter(num, property))
}, 300)
}, [onChange, property])
return (
<div className="flex items-center gap-1">
<input
type="number"
defaultValue={numericValue}
onChange={handleChange}
min={property.min}
step={property.unit ? 1 : 0.1}
className="w-20 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
/>
{property.unit && (
<span className="text-[11px] text-muted-foreground">{property.unit}</span>
)}
</div>
)
}
function ColorInput({ property, value, onChange }: {
property: ThemeProperty
value: string
onChange: (val: string) => void
}) {
const [localValue, setLocalValue] = useState(value)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const showSwatch = isValidCssColor(localValue)
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = e.target.value
setLocalValue(newVal)
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => onChange(newVal), 300)
}, [onChange])
const handlePickerChange = useCallback((hex: string) => {
setLocalValue(hex)
onChange(hex)
}, [onChange])
return (
<div className="flex items-center gap-1.5">
{showSwatch && <ColorSwatch color={localValue} onChange={handlePickerChange} />}
<input
type="text"
value={localValue}
onChange={handleTextChange}
className="w-28 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
/>
</div>
)
}
function SelectInput({ property, value, onChange }: {
property: ThemeProperty
value: string
onChange: (val: string) => void
}) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className="w-28 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
>
{property.options?.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
)
}
function TextInput({ property, value, onChange }: {
property: ThemeProperty
value: string
onChange: (val: string) => void
}) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = e.target.value
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => onChange(newVal), 500)
}, [onChange])
return (
<input
type="text"
defaultValue={value}
onChange={handleChange}
className="w-40 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
/>
)
}
// --- Property row ---
function PropertyRow({ property, currentValue, onUpdate }: {
property: ThemeProperty
currentValue: string | undefined
onUpdate: (cssVar: string, value: string) => void
}) {
const displayValue = currentValue !== undefined
? parseValueFromFrontmatter(currentValue, property)
: property.defaultValue
const isPlaceholder = currentValue === undefined
const handleChange = useCallback((val: string) => {
onUpdate(property.cssVar, val)
}, [property.cssVar, onUpdate])
return (
<div
className="flex items-center justify-between gap-2 py-1"
style={{ minHeight: 28 }}
>
<label
className="text-xs shrink-0"
style={{ color: isPlaceholder ? 'var(--muted-foreground)' : 'var(--foreground)', minWidth: 100 }}
>
{property.label}
</label>
<div className="flex-shrink-0">
{property.inputType === 'number' && (
<NumberInput property={property} value={displayValue} onChange={handleChange} />
)}
{property.inputType === 'color' && (
<ColorInput property={property} value={String(displayValue)} onChange={handleChange} />
)}
{property.inputType === 'select' && (
<SelectInput property={property} value={String(displayValue)} onChange={handleChange} />
)}
{property.inputType === 'text' && (
<TextInput property={property} value={String(displayValue)} onChange={handleChange} />
)}
</div>
</div>
)
}
// --- Collapsible section ---
function CollapsibleSection({ label, defaultOpen, children, testId }: {
label: string
defaultOpen?: boolean
children: React.ReactNode
testId?: string
}) {
const [open, setOpen] = useState(defaultOpen ?? false)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setOpen(prev => !prev)
}
}, [])
return (
<div data-testid={testId}>
<button
type="button"
className="flex w-full items-center gap-1 border-none bg-transparent p-0 cursor-pointer"
style={{ fontSize: 12, fontWeight: 600, color: 'var(--foreground)', padding: '4px 0' }}
onClick={() => setOpen(prev => !prev)}
onKeyDown={handleKeyDown}
aria-expanded={open}
data-testid={testId ? `${testId}-toggle` : undefined}
>
<CaretRight
size={12}
weight="bold"
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}
/>
{label}
</button>
{open && (
<div style={{ paddingLeft: 16 }}>
{children}
</div>
)}
</div>
)
}
// --- Section renderers ---
function SubsectionBlock({ subsection, currentValues, onUpdate }: {
subsection: ThemeSubsection
currentValues: Record<string, string>
onUpdate: (cssVar: string, value: string) => void
}) {
return (
<CollapsibleSection label={subsection.label} testId={`theme-sub-${subsection.id}`}>
{subsection.properties.map(prop => (
<PropertyRow
key={prop.cssVar}
property={prop}
currentValue={currentValues[prop.cssVar]}
onUpdate={onUpdate}
/>
))}
</CollapsibleSection>
)
}
function SectionBlock({ section, currentValues, onUpdate }: {
section: ThemeSection
currentValues: Record<string, string>
onUpdate: (cssVar: string, value: string) => void
}) {
return (
<CollapsibleSection
label={section.label}
defaultOpen={section.id === 'editor'}
testId={`theme-section-${section.id}`}
>
{section.properties.map(prop => (
<PropertyRow
key={prop.cssVar}
property={prop}
currentValue={currentValues[prop.cssVar]}
onUpdate={onUpdate}
/>
))}
{section.subsections.map(sub => (
<SubsectionBlock
key={sub.id}
subsection={sub}
currentValues={currentValues}
onUpdate={onUpdate}
/>
))}
</CollapsibleSection>
)
}
// --- Main component ---
export function ThemePropertyEditor({ themeManager }: { themeManager: ThemeManager }) {
const schema = useMemo(() => getThemeSchema(), [])
const currentValues = useThemeValues(themeManager.activeThemeContent)
const handleUpdate = useCallback((cssVar: string, value: string) => {
themeManager.updateThemeProperty(cssVar, value)
}, [themeManager])
if (!themeManager.activeThemeId) {
return (
<div className="text-xs text-muted-foreground" style={{ padding: '8px 0' }}>
Select a theme to customize its properties.
</div>
)
}
return (
<div
className="flex flex-col gap-1"
data-testid="theme-property-editor"
style={{ fontSize: 12 }}
>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', marginBottom: 4 }}>
Editing: <strong>{themeManager.activeTheme?.name ?? 'Theme'}</strong>
</div>
{schema.map(section => (
<SectionBlock
key={section.id}
section={section}
currentValues={currentValues}
onUpdate={handleUpdate}
/>
))}
</div>
)
}

View File

@@ -22,17 +22,19 @@ function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
if (!isA) return null
return (
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5">
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
{onNavigate ? (
<button
className="min-w-0 truncate border-none text-right cursor-pointer hover:opacity-80"
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
>{isA}</button>
) : (
<span className="text-right text-[12px] text-secondary-foreground">{isA}</span>
)}
<div className="min-w-0">
{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 }}
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
>{isA}</button>
) : (
<span className="text-[12px] text-secondary-foreground">{isA}</span>
)}
</div>
</div>
)
}
@@ -51,17 +53,28 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
? [...availableTypes, isA].sort((a, b) => a.localeCompare(b))
: availableTypes
const typeColor = isA ? getTypeColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined
const typeLightColor = isA ? getTypeLightColor(isA, typeColorKeys[isA] ?? customColorKey) : undefined
return (
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="type-selector">
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="type-selector">
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
<SelectTrigger
size="sm"
className="h-[26px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
style={{ fontSize: 12, borderRadius: 4 }}
>
<SelectValue placeholder="None" />
</SelectTrigger>
<div className="min-w-0">
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
<SelectTrigger
size="sm"
className={`h-auto max-w-full gap-1 border-none shadow-none [&_svg]:text-current ring-inset${isA ? ' hover:ring-1 hover:ring-current' : ' bg-muted hover:opacity-80'}`}
style={{
background: typeLightColor ?? undefined,
color: typeColor ?? undefined,
borderRadius: 6,
padding: '4px 8px',
fontSize: 12,
fontWeight: 500,
}}
>
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent position="popper" side="left">
<SelectItem value={TYPE_NONE}>None</SelectItem>
<SelectSeparator />
@@ -71,7 +84,8 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
</SelectItem>
))}
</SelectContent>
</Select>
</Select>
</div>
</div>
)
}

View File

@@ -15,14 +15,14 @@ const PILLS: { value: NoteListFilter; label: string }[] = [
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
return (
<div className="flex h-[45px] shrink-0 items-center gap-1 border-b border-border px-4" data-testid="filter-pills">
<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">
{PILLS.map(({ value, label }) => (
<button
key={value}
type="button"
role="tab"
aria-selected={active === value}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
active === value
? 'border-foreground/20 bg-foreground/10 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'

View File

@@ -0,0 +1,44 @@
import { memo } from 'react'
import type { InboxPeriod } from '../../types'
interface InboxFilterPillsProps {
active: InboxPeriod
counts: Record<InboxPeriod, number>
onChange: (period: InboxPeriod) => void
}
const PILLS: { value: InboxPeriod; label: string }[] = [
{ value: 'week', label: 'This week' },
{ value: 'month', label: 'This month' },
{ value: 'quarter', label: 'This quarter' },
{ value: 'all', label: 'All time' },
]
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {
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">
{PILLS.map(({ value, label }) => (
<button
key={value}
type="button"
role="tab"
aria-selected={active === value}
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
active === value
? 'border-foreground/20 bg-foreground/10 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
onClick={() => onChange(value)}
data-testid={`inbox-pill-${value}`}
>
{label}
<span className={`text-[10px] tabular-nums ${active === value ? 'text-foreground/70' : 'text-muted-foreground/70'}`}>
{counts[value]}
</span>
</button>
))}
</div>
)
}
export const InboxFilterPills = memo(InboxFilterPillsInner)

View File

@@ -11,11 +11,12 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, query: string): string {
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, isInboxView: boolean, query: string): string {
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
if (isChangesView) return 'No pending changes'
if (isTrashView) return 'Trash is empty'
if (isArchivedView) return 'No archived notes'
if (isInboxView) return query ? 'No matching notes' : 'All notes are organized'
return query ? 'No matching notes' : 'No notes found'
}
@@ -39,13 +40,13 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
)
}
export function ListView({ isTrashView, isArchivedView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isArchivedView?: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
export function ListView({ isTrashView, isArchivedView, isChangesView, isInboxView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isArchivedView?: boolean; isChangesView?: boolean; isInboxView?: boolean; changesError?: string | null; expiredTrashCount: number
deletedCount?: number; searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, query)
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, !!isInboxView, query)
const hasHeader = isTrashView && expiredTrashCount > 0
const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0

View File

@@ -3,10 +3,11 @@ import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../
import {
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
getSortComparator, extractSortableProperties,
buildRelationshipGroups, filterEntries,
buildRelationshipGroups, filterEntries, filterInboxEntries,
loadSortPreferences, saveSortPreferences,
parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage,
} from '../../utils/noteListHelpers'
import type { InboxPeriod } from '../../types'
import { buildTypeEntryMap } from '../../utils/typeColors'
import { filterByQuery, filterGroupsByQuery, countExpiredTrash, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
@@ -19,14 +20,16 @@ export function useTypeEntryMap(entries: VaultEntry[]) {
// --- useFilteredEntries ---
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter) {
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod) {
const isEntityView = selection.kind === 'entity'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
return useMemo(() => {
if (isEntityView) return []
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month')
return filterEntries(entries, selection, subFilter)
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes, subFilter])
}, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod])
}
// --- useNoteListData ---
@@ -36,14 +39,15 @@ interface NoteListDataParams {
query: string; listSort: SortOption; listDirection: SortDirection
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
subFilter?: NoteListFilter
inboxPeriod?: InboxPeriod
}
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter }: NoteListDataParams) {
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = (selection.kind === 'filter' && selection.filter === 'trash') || subFilter === 'trashed'
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter)
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod)
const searched = useMemo(() => {
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
@@ -52,7 +56,10 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
const groups = buildRelationshipGroups(selection.entry, entries)
// Look up the fresh entry from the entries array to pick up relationship
// updates that happened after the selection was captured.
const freshEntry = entries.find((e) => e.path === selection.entry.path) ?? selection.entry
const groups = buildRelationshipGroups(freshEntry, entries)
return filterGroupsByQuery(groups, query)
}, [isEntityView, selection, entries, query])
@@ -125,11 +132,12 @@ export interface UseNoteListSortParams {
modifiedPathSet: Set<string>
modifiedSuffixes: string[]
subFilter?: NoteListFilter
inboxPeriod?: InboxPeriod
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const typeDocument = useMemo(() => {
@@ -157,7 +165,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
}
}, [typeDocument, persistence])
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter)
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod)
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi } from 'vitest'
import { routeNoteClick, type ClickActions } from './noteListUtils'
import type { VaultEntry } from '../../types'
function makeEntry(path = '/test.md'): VaultEntry {
return {
path, filename: 'test.md', title: 'Test', isA: null,
aliases: [], belongsTo: [], relatedTo: [], status: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: null, createdAt: null, fileSize: 0,
snippet: '', wordCount: 0, relationships: {},
icon: null, color: null, order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
}
}
function makeActions(): ClickActions {
return {
onReplace: vi.fn(),
onSelect: vi.fn(),
onOpenInNewWindow: vi.fn(),
multiSelect: {
selectRange: vi.fn(),
clear: vi.fn(),
setAnchor: vi.fn(),
},
}
}
function makeMouseEvent(overrides: Partial<React.MouseEvent> = {}): React.MouseEvent {
return { metaKey: false, ctrlKey: false, shiftKey: false, ...overrides } as React.MouseEvent
}
describe('routeNoteClick', () => {
it('plain click replaces active tab', () => {
const entry = makeEntry()
const actions = makeActions()
routeNoteClick(entry, makeMouseEvent(), actions)
expect(actions.onReplace).toHaveBeenCalledWith(entry)
expect(actions.multiSelect.clear).toHaveBeenCalled()
expect(actions.multiSelect.setAnchor).toHaveBeenCalledWith(entry.path)
})
it('Cmd+click opens as new tab', () => {
const entry = makeEntry()
const actions = makeActions()
routeNoteClick(entry, makeMouseEvent({ metaKey: true }), actions)
expect(actions.onSelect).toHaveBeenCalledWith(entry)
expect(actions.multiSelect.clear).toHaveBeenCalled()
})
it('Shift+click selects range', () => {
const entry = makeEntry()
const actions = makeActions()
routeNoteClick(entry, makeMouseEvent({ shiftKey: true }), actions)
expect(actions.multiSelect.selectRange).toHaveBeenCalledWith(entry.path)
})
it('Cmd+Shift+click opens in new window', () => {
const entry = makeEntry()
const actions = makeActions()
routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions)
expect(actions.onOpenInNewWindow).toHaveBeenCalledWith(entry)
expect(actions.onReplace).not.toHaveBeenCalled()
expect(actions.onSelect).not.toHaveBeenCalled()
})
it('Cmd+Shift+click is a no-op when handler is undefined', () => {
const entry = makeEntry()
const actions = makeActions()
actions.onOpenInNewWindow = undefined
routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions)
expect(actions.onReplace).not.toHaveBeenCalled()
expect(actions.onSelect).not.toHaveBeenCalled()
})
})

View File

@@ -7,6 +7,7 @@ export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: Va
if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive'
if (selection.kind === 'filter' && selection.filter === 'trash') return 'Trash'
if (selection.kind === 'filter' && selection.filter === 'changes') return 'Changes'
if (selection.kind === 'filter' && selection.filter === 'inbox') return 'Inbox'
return 'Notes'
}
@@ -27,11 +28,13 @@ export function countExpiredTrash(entries: VaultEntry[]): number {
export interface ClickActions {
onReplace: (entry: VaultEntry) => void
onSelect: (entry: VaultEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
multiSelect: { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
}
export function routeNoteClick(entry: VaultEntry, e: React.MouseEvent, actions: ClickActions) {
if (e.shiftKey) { actions.multiSelect.selectRange(entry.path) }
if ((e.metaKey || e.ctrlKey) && e.shiftKey) { actions.onOpenInNewWindow?.(entry) }
else if (e.shiftKey) { actions.multiSelect.selectRange(entry.path) }
else if (e.metaKey || e.ctrlKey) { actions.multiSelect.clear(); actions.onSelect(entry) }
else { actions.multiSelect.clear(); actions.multiSelect.setAnchor(entry.path); actions.onReplace(entry) }
}

View File

@@ -85,11 +85,11 @@ export const frontmatterHighlightPlugin = ViewPlugin.fromClass(
{ decorations: (v) => v.decorations },
)
export function frontmatterHighlightTheme(isDark: boolean) {
const keyColor = isDark ? '#f0a0a0' : '#c9383e'
const valueColor = isDark ? '#a0d0a0' : '#2a7e4f'
const delimiterColor = isDark ? '#f0a0a0' : '#c9383e'
const headingColor = isDark ? '#88c0ff' : '#0969da'
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' },

View File

@@ -13,12 +13,38 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
template: { template: null }, sort: { sort: null }, visible: { visible: null },
}
/** Check if a string contains a wikilink pattern `[[...]]`. */
function isWikilink(s: string): boolean {
return s.startsWith('[[') && s.includes(']]')
}
/** Extract wikilink strings from a FrontmatterValue. Returns empty array if none. */
function extractWikilinks(value: FrontmatterValue): string[] {
if (typeof value === 'string') return isWikilink(value) ? [value] : []
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string' && isWikilink(v))
return []
}
/**
* Relationship patch: a partial update to merge into `entry.relationships`.
* Keys map to their new ref arrays. A `null` value means "remove this key".
*/
export type RelationshipPatch = Record<string, string[] | null>
export interface EntryPatchResult {
patch: Partial<VaultEntry>
relationshipPatch: RelationshipPatch | null
}
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
export function frontmatterToEntryPatch(
op: 'update' | 'delete', key: string, value?: FrontmatterValue,
): Partial<VaultEntry> {
): EntryPatchResult {
const k = key.toLowerCase().replace(/\s+/g, '_')
if (op === 'delete') return ENTRY_DELETE_MAP[k] ?? {}
if (op === 'delete') {
const relPatch: RelationshipPatch = { [key]: null }
return { patch: ENTRY_DELETE_MAP[k] ?? {}, relationshipPatch: relPatch }
}
const str = value != null ? String(value) : null
const arr = Array.isArray(value) ? value.map(String) : []
const updates: Record<string, Partial<VaultEntry>> = {
@@ -32,7 +58,11 @@ export function frontmatterToEntryPatch(
view: { view: str },
visible: { visible: value === false ? false : null },
}
return updates[k] ?? {}
// Also update the relationships map for wikilink-containing values
const wikilinks = value != null ? extractWikilinks(value) : []
const relationshipPatch: RelationshipPatch | null =
wikilinks.length > 0 ? { [key]: wikilinks } : null
return { patch: updates[k] ?? {}, relationshipPatch }
}
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
@@ -65,18 +95,42 @@ export interface FrontmatterOpOptions {
silent?: boolean
}
/** Apply a relationship patch by merging into the existing relationships map. */
export function applyRelationshipPatch(
existing: Record<string, string[]>, relPatch: RelationshipPatch,
): Record<string, string[]> {
const merged = { ...existing }
for (const [k, v] of Object.entries(relPatch)) {
if (v === null) delete merged[k]
else merged[k] = v
}
return merged
}
/** Run a frontmatter update/delete and apply the result to state.
* Returns the new file content on success, or undefined on failure. */
export async function runFrontmatterAndApply(
op: 'update' | 'delete', path: string, key: string, value: FrontmatterValue | undefined,
callbacks: { updateTab: (p: string, c: string) => void; updateEntry: (p: string, patch: Partial<VaultEntry>) => void; toast: (m: string | null) => void },
callbacks: {
updateTab: (p: string, c: string) => void
updateEntry: (p: string, patch: Partial<VaultEntry>) => void
toast: (m: string | null) => void
getEntry?: (p: string) => VaultEntry | undefined
},
options?: FrontmatterOpOptions,
): Promise<string | undefined> {
try {
const newContent = await executeFrontmatterOp(op, path, key, value)
callbacks.updateTab(path, newContent)
const patch = frontmatterToEntryPatch(op, key, value)
if (Object.keys(patch).length > 0) callbacks.updateEntry(path, patch)
const { patch, relationshipPatch } = frontmatterToEntryPatch(op, key, value)
const fullPatch = { ...patch }
if (relationshipPatch && callbacks.getEntry) {
const current = callbacks.getEntry(path)
if (current) {
fullPatch.relationships = applyRelationshipPatch(current.relationships, relationshipPatch)
}
}
if (Object.keys(fullPatch).length > 0) callbacks.updateEntry(path, fullPatch)
if (!options?.silent) callbacks.toast(op === 'update' ? 'Property updated' : 'Property deleted')
return newContent
} catch (err) {

View File

@@ -4,7 +4,7 @@ import { useCommandRegistry } from './useCommandRegistry'
import type { CommandAction } from './useCommandRegistry'
import { useKeyboardNavigation } from './useKeyboardNavigation'
import { useMenuEvents } from './useMenuEvents'
import type { SidebarSelection, ThemeFile, VaultEntry } from '../types'
import type { SidebarSelection, SidebarFilter, VaultEntry } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
@@ -31,6 +31,7 @@ interface AppCommandsConfig {
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
onPull?: () => void
onResolveConflicts?: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
@@ -50,18 +51,12 @@ interface AppCommandsConfig {
onGoForward?: () => void
canGoBack?: boolean
canGoForward?: boolean
themes?: ThemeFile[]
activeThemeId?: string | null
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
onOpenTheme?: (themeId: string) => void
onOpenVault?: () => void
onCreateType?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onRestoreDefaultThemes?: () => void
isGettingStartedHidden?: boolean
vaultCount?: number
mcpStatus?: string
@@ -69,7 +64,6 @@ interface AppCommandsConfig {
onEmptyTrash?: () => void
trashedCount?: number
onReopenClosedTab?: () => void
onReindexVault?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onSetNoteIcon?: () => void
@@ -77,6 +71,7 @@ interface AppCommandsConfig {
activeNoteHasIcon?: boolean
noteListFilter?: NoteListFilter
onSetNoteListFilter?: (filter: NoteListFilter) => void
onOpenInNewWindow?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -97,7 +92,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
const { onSelect } = config
const selectFilter = useCallback((filter: 'all' | 'archived' | 'trash' | 'changes' | 'pulse') => {
const selectFilter = useCallback((filter: SidebarFilter) => {
onSelect({ kind: 'filter', filter })
}, [onSelect])
@@ -124,6 +119,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onToggleAIChat: config.onToggleAIChat,
onToggleRawEditor: config.onToggleRawEditor,
onReopenClosedTab: config.onReopenClosedTab,
onOpenInNewWindow: config.onOpenInNewWindow,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
})
@@ -154,17 +150,16 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onOpenVault: config.onOpenVault,
onRemoveActiveVault: config.onRemoveActiveVault,
onRestoreGettingStarted: config.onRestoreGettingStarted,
onCreateTheme: config.onCreateTheme,
onRestoreDefaultThemes: config.onRestoreDefaultThemes,
onCommitPush: config.onCommitPush,
onPull: config.onPull,
onResolveConflicts: config.onResolveConflicts,
onViewChanges: viewChanges,
onInstallMcp: config.onInstallMcp,
onReindexVault: config.onReindexVault,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
onEmptyTrash: config.onEmptyTrash,
onReopenClosedTab: config.onReopenClosedTab,
onOpenInNewWindow: config.onOpenInNewWindow,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,
@@ -185,6 +180,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onArchiveNote: config.onArchiveNote,
onUnarchiveNote: config.onUnarchiveNote,
onCommitPush: config.onCommitPush,
onPull: config.onPull,
onResolveConflicts: config.onResolveConflicts,
onSetViewMode: config.onSetViewMode,
onToggleInspector: config.onToggleInspector,
@@ -204,23 +200,16 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onGoForward: config.onGoForward,
canGoBack: config.canGoBack,
canGoForward: config.canGoForward,
themes: config.themes,
activeThemeId: config.activeThemeId,
onSwitchTheme: config.onSwitchTheme,
onCreateTheme: config.onCreateTheme,
onOpenTheme: config.onOpenTheme,
onCheckForUpdates: config.onCheckForUpdates,
onCreateType: config.onCreateType,
onRemoveActiveVault: config.onRemoveActiveVault,
onRestoreGettingStarted: config.onRestoreGettingStarted,
onRestoreDefaultThemes: config.onRestoreDefaultThemes,
isGettingStartedHidden: config.isGettingStartedHidden,
vaultCount: config.vaultCount,
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
onEmptyTrash: config.onEmptyTrash,
trashedCount: config.trashedCount,
onReindexVault: config.onReindexVault,
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
onSetNoteIcon: config.onSetNoteIcon,
@@ -229,6 +218,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
selection: config.selection,
noteListFilter: config.noteListFilter,
onSetNoteListFilter: config.onSetNoteListFilter,
onOpenInNewWindow: config.onOpenInNewWindow,
})
useKeyboardNavigation({

View File

@@ -217,4 +217,12 @@ describe('useAppKeyboard', () => {
expect(onToggleAIChat).toHaveBeenCalled()
})
})
it('Cmd+Shift+O triggers open in new window', () => {
const actions = makeActions()
const onOpenInNewWindow = vi.fn()
renderHook(() => useAppKeyboard({ ...actions, onOpenInNewWindow }))
fireKey('o', { metaKey: true, shiftKey: true })
expect(onOpenInNewWindow).toHaveBeenCalled()
})
})

View File

@@ -20,6 +20,7 @@ interface KeyboardActions {
onToggleAIChat?: () => void
onToggleRawEditor?: () => void
onReopenClosedTab?: () => void
onOpenInNewWindow?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
@@ -65,7 +66,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
export function useAppKeyboard({
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, activeTabPathRef, handleCloseTabRef,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, onOpenInNewWindow, activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
useEffect(() => {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
@@ -107,11 +108,17 @@ export function useAppKeyboard({
onReopenClosedTab?.()
return
}
// Cmd+Shift+O: open active note in new window
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'o' || e.key === 'O')) {
e.preventDefault()
onOpenInNewWindow?.()
return
}
if (!handleViewModeKey(e, onSetViewMode)) {
handleCmdKey(e, cmdKeyMap)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab])
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, onOpenInNewWindow])
}

View File

@@ -107,20 +107,32 @@ describe('useAutoSync', () => {
})
})
it('pulls on window focus', async () => {
it('pulls on window focus after cooldown expires', async () => {
const now = vi.spyOn(Date, 'now')
let clock = 1000
now.mockImplementation(() => clock)
renderSync()
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
})
// Focus within cooldown — should NOT trigger pull
mockInvokeFn.mockClear()
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
clock += 5_000 // only 5s later
await act(async () => { window.dispatchEvent(new Event('focus')) })
const pullCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'git_pull')
expect(pullCalls).toHaveLength(0)
// Focus after cooldown — should trigger pull
clock += 30_000 // 30s later
await act(async () => { window.dispatchEvent(new Event('focus')) })
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
})
now.mockRestore()
})
it('triggerSync allows manual pull', async () => {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { GitPullResult, LastCommitInfo, SyncStatus } from '../types'
import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
const DEFAULT_INTERVAL_MS = 5 * 60_000
@@ -23,11 +23,16 @@ export interface AutoSyncState {
lastSyncTime: number | null
conflictFiles: string[]
lastCommitInfo: LastCommitInfo | null
remoteStatus: GitRemoteStatus | null
triggerSync: () => void
/** Pull from remote, then push if there are local commits ahead. */
pullAndPush: () => void
/** Pause auto-pull (e.g. while conflict resolver modal is open). */
pausePull: () => void
/** Resume auto-pull after pausing. */
resumePull: () => void
/** Notify that a push was rejected so the status updates to pull_required. */
handlePushRejected: () => void
}
export function useAutoSync({
@@ -42,11 +47,22 @@ export function useAutoSync({
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null)
const [conflictFiles, setConflictFiles] = useState<string[]>([])
const [lastCommitInfo, setLastCommitInfo] = useState<LastCommitInfo | null>(null)
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null)
const syncingRef = useRef(false)
const pauseRef = useRef(false)
const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
const refreshRemoteStatus = useCallback(async () => {
try {
const status = await tauriCall<GitRemoteStatus>('git_remote_status', { vaultPath })
setRemoteStatus(status)
return status
} catch {
return null
}
}, [vaultPath])
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
const checkExistingConflicts = useCallback(async (): Promise<boolean> => {
try {
@@ -63,6 +79,12 @@ export function useAutoSync({
return false
}, [vaultPath])
const refreshCommitInfo = useCallback(() => {
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
.then(info => setLastCommitInfo(info))
.catch(() => {})
}, [vaultPath])
const performPull = useCallback(async () => {
if (syncingRef.current || pauseRef.current) return
syncingRef.current = true
@@ -71,9 +93,7 @@ export function useAutoSync({
try {
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
setLastSyncTime(Date.now())
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
.then(info => setLastCommitInfo(info))
.catch(() => {})
refreshCommitInfo()
if (result.status === 'updated') {
setSyncStatus('idle')
@@ -96,24 +116,95 @@ export function useAutoSync({
setSyncStatus('idle')
setConflictFiles([])
}
// Refresh remote status after pull
refreshRemoteStatus()
} catch {
setSyncStatus('error')
setLastSyncTime(Date.now())
} finally {
syncingRef.current = false
}
}, [vaultPath, checkExistingConflicts])
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
/** Pull from remote, then auto-push if successful. Used for divergence recovery. */
const pullAndPush = useCallback(async () => {
if (syncingRef.current) return
syncingRef.current = true
setSyncStatus('syncing')
try {
const pullResult = await tauriCall<GitPullResult>('git_pull', { vaultPath })
setLastSyncTime(Date.now())
refreshCommitInfo()
if (pullResult.status === 'conflict') {
setSyncStatus('conflict')
setConflictFiles(pullResult.conflictFiles)
callbacksRef.current.onConflict(pullResult.conflictFiles)
return
}
if (pullResult.status === 'error') {
const hasConflicts = await checkExistingConflicts()
if (!hasConflicts) {
setSyncStatus('error')
callbacksRef.current.onToast('Pull failed: ' + pullResult.message)
}
return
}
if (pullResult.status === 'updated') {
callbacksRef.current.onVaultUpdated()
callbacksRef.current.onSyncUpdated?.()
}
// Now push
const pushResult = await tauriCall<GitPushResult>('git_push', { vaultPath })
if (pushResult.status === 'ok') {
setSyncStatus('idle')
setConflictFiles([])
callbacksRef.current.onToast('Pulled and pushed successfully')
} else if (pushResult.status === 'rejected') {
// Still diverged — shouldn't happen after pull but handle gracefully
setSyncStatus('pull_required')
callbacksRef.current.onToast('Push still rejected after pull — try again')
} else {
setSyncStatus('error')
callbacksRef.current.onToast(pushResult.message)
}
refreshRemoteStatus()
} catch {
setSyncStatus('error')
setLastSyncTime(Date.now())
} finally {
syncingRef.current = false
}
}, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus])
const handlePushRejected = useCallback(() => {
setSyncStatus('pull_required')
}, [])
// Check for pre-existing conflicts on mount, then pull
useEffect(() => {
checkExistingConflicts().then(hasConflicts => {
if (!hasConflicts) performPull()
})
}, [checkExistingConflicts, performPull])
refreshRemoteStatus()
}, [checkExistingConflicts, performPull, refreshRemoteStatus])
// Pull on window focus (app foreground)
// Pull on window focus (app foreground) — with cooldown to avoid repeated pulls
const lastPullTimeRef = useRef(0)
useEffect(() => {
const handleFocus = () => { performPull() }
const FOCUS_COOLDOWN_MS = 30_000
const handleFocus = () => {
const now = Date.now()
if (now - lastPullTimeRef.current < FOCUS_COOLDOWN_MS) return
lastPullTimeRef.current = now
performPull()
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [performPull])
@@ -128,5 +219,5 @@ export function useAutoSync({
const pausePull = useCallback(() => { pauseRef.current = true }, [])
const resumePull = useCallback(() => { pauseRef.current = false }, [])
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, triggerSync: performPull, pausePull, resumePull }
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync: performPull, pullAndPush, pausePull, resumePull, handlePushRejected }
}

View File

@@ -14,13 +14,13 @@ export interface CodeMirrorCallbacks {
onEscape: () => boolean
}
function buildBaseTheme(isDark: boolean) {
const bg = isDark ? '#1e1e1e' : '#ffffff'
const fg = isDark ? '#d4d4d4' : '#1e1e1e'
const gutterBg = isDark ? '#1e1e1e' : '#ffffff'
const gutterColor = isDark ? '#555' : '#aaa'
const activeLineBg = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,100,255,0.06)'
const gutterBorder = isDark ? '#333' : '#eee'
function buildBaseTheme() {
const bg = '#ffffff'
const fg = '#1e1e1e'
const gutterBg = '#ffffff'
const gutterColor = '#aaa'
const activeLineBg = 'rgba(0,100,255,0.06)'
const gutterBorder = '#eee'
return EditorView.theme({
'&': {
@@ -60,7 +60,7 @@ function buildBaseTheme(isDark: boolean) {
},
'&.cm-focused': { outline: 'none' },
'.cm-line': { padding: '0' },
}, { dark: isDark })
})
}
function buildSaveKeymap(callbacks: { current: CodeMirrorCallbacks }) {
@@ -76,7 +76,6 @@ function buildSaveKeymap(callbacks: { current: CodeMirrorCallbacks }) {
export function useCodeMirror(
containerRef: React.RefObject<HTMLDivElement | null>,
content: string,
isDark: boolean,
callbacks: CodeMirrorCallbacks,
) {
const viewRef = useRef<EditorView | null>(null)
@@ -109,8 +108,8 @@ export function useCodeMirror(
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
buildSaveKeymap(callbacksRef),
buildBaseTheme(isDark),
frontmatterHighlightTheme(isDark),
buildBaseTheme(),
frontmatterHighlightTheme(),
frontmatterHighlightPlugin,
zoomCursorFix(),
EditorView.updateListener.of((update) => {
@@ -144,9 +143,8 @@ export function useCodeMirror(
view.destroy()
viewRef.current = null
}
// Re-create editor when isDark changes (theme is baked into extensions)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDark])
}, [])
return viewRef
}

View File

@@ -95,46 +95,6 @@ describe('useCommandRegistry', () => {
expect(cmd!.enabled).toBe(false)
})
it('includes reindex-vault command in Settings group', () => {
const config = makeConfig({ onReindexVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('Settings')
expect(cmd!.label).toBe('Reindex Vault')
})
it('reindex-vault is enabled when onReindexVault is provided', () => {
const config = makeConfig({ onReindexVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd!.enabled).toBe(true)
})
it('reindex-vault is disabled when onReindexVault is not provided', () => {
const config = makeConfig()
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd!.enabled).toBe(false)
})
it('reindex-vault executes onReindexVault callback', () => {
const onReindexVault = vi.fn()
const config = makeConfig({ onReindexVault })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
cmd!.execute()
expect(onReindexVault).toHaveBeenCalled()
})
it('reindex-vault has searchable keywords', () => {
const config = makeConfig({ onReindexVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd!.keywords).toContain('reindex')
expect(cmd!.keywords).toContain('search')
})
it('resolve-conflicts stays enabled across rerenders', () => {
const config = makeConfig()
const { result, rerender } = renderHook(

View File

@@ -1,9 +1,9 @@
import { useMemo } from 'react'
import type { SidebarSelection, ThemeFile, VaultEntry } from '../types'
import type { SidebarSelection, VaultEntry } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import type { ViewMode } from './useViewMode'
export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Appearance' | 'Settings'
export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Settings'
export interface CommandAction {
id: string
@@ -25,11 +25,11 @@ interface CommandRegistryConfig {
onInstallMcp?: () => void
onEmptyTrash?: () => void
trashedCount?: number
onReindexVault?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onOpenInNewWindow?: () => void
onQuickOpen: () => void
onCreateNote: () => void
@@ -43,6 +43,7 @@ interface CommandRegistryConfig {
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
onPull?: () => void
onResolveConflicts?: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
@@ -62,14 +63,8 @@ interface CommandRegistryConfig {
onGoForward?: () => void
canGoBack?: boolean
canGoForward?: boolean
themes?: ThemeFile[]
activeThemeId?: string | null
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
onOpenTheme?: (themeId: string) => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onRestoreDefaultThemes?: () => void
isGettingStartedHidden?: boolean
vaultCount?: number
/** Current selection — used to scope filter pill commands to section group views. */
@@ -101,7 +96,7 @@ export function extractVaultTypes(entries: VaultEntry[]): string[] {
return Array.from(typeSet).sort()
}
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Appearance', 'Settings']
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings']
export function groupSortKey(group: CommandGroup): number {
return GROUP_ORDER.indexOf(group)
@@ -158,65 +153,27 @@ export function buildViewCommands(
]
}
export function buildThemeCommands(
themes: ThemeFile[] | undefined,
activeThemeId: string | null | undefined,
onSwitchTheme: ((themeId: string) => void) | undefined,
onCreateTheme: (() => void) | undefined,
onOpenTheme: ((themeId: string) => void) | undefined,
): CommandAction[] {
const cmds: CommandAction[] = []
for (const t of (themes ?? [])) {
cmds.push({
id: `switch-theme-${t.id}`,
label: `Switch to ${t.name} Theme`,
group: 'Appearance' as CommandGroup,
keywords: ['theme', 'appearance', 'color', t.name.toLowerCase()],
enabled: t.id !== activeThemeId,
execute: () => onSwitchTheme?.(t.id),
})
if (onOpenTheme) {
cmds.push({
id: `open-theme-${t.id}`,
label: `Edit ${t.name} Theme`,
group: 'Appearance' as CommandGroup,
keywords: ['theme', 'edit', 'open', 'appearance', t.name.toLowerCase()],
enabled: true,
execute: () => onOpenTheme(t.id),
})
}
}
if (onCreateTheme) {
cmds.push({
id: 'new-theme', label: 'New Theme', group: 'Appearance' as CommandGroup,
keywords: ['theme', 'create', 'appearance'], enabled: true, execute: onCreateTheme,
})
}
return cmds
}
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
const {
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
onCheckForUpdates,
onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onEmptyTrash, trashedCount,
onReindexVault,
onReloadVault,
onRepairVault,
onSetNoteIcon,
onRemoveNoteIcon,
activeNoteHasIcon,
onOpenInNewWindow,
selection, noteListFilter, onSetNoteListFilter,
} = config
@@ -242,6 +199,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
{ id: 'go-inbox', label: 'Go to Inbox', group: 'Navigation', keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'inbox' }) },
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
@@ -273,19 +231,22 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
execute: () => onRemoveNoteIcon?.(),
},
{
id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O',
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
enabled: hasActiveNote,
execute: () => onOpenInNewWindow?.(),
},
// Git
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() },
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
// View
...buildViewCommands(hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
// Appearance
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme),
{ id: 'restore-default-themes', label: 'Restore Default Themes', group: 'Appearance', keywords: ['theme', 'reset', 'restore', 'default', 'fix', 'missing'], enabled: true, execute: () => onRestoreDefaultThemes?.() },
// Settings
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
@@ -293,7 +254,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
{ id: 'reindex-vault', label: 'Reindex Vault', group: 'Settings', keywords: ['reindex', 'index', 'search', 'rebuild', 'refresh'], enabled: !!onReindexVault, execute: () => onReindexVault?.() },
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
@@ -311,17 +271,17 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
vaultTypes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onEmptyTrash, trashedCount,
onReindexVault, onReloadVault, onRepairVault,
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
isSectionGroup, noteListFilter, onSetNoteListFilter,
onOpenInNewWindow,
])
}

View File

@@ -7,16 +7,18 @@ describe('useCommitFlow', () => {
let loadModifiedFiles: vi.Mock
let commitAndPush: vi.Mock
let setToastMessage: vi.Mock
let onPushRejected: vi.Mock
beforeEach(() => {
savePending = vi.fn().mockResolvedValue(undefined)
loadModifiedFiles = vi.fn().mockResolvedValue(undefined)
commitAndPush = vi.fn().mockResolvedValue('Committed and pushed')
commitAndPush = vi.fn().mockResolvedValue({ status: 'ok', message: 'Pushed to remote' })
setToastMessage = vi.fn()
onPushRejected = vi.fn()
})
function renderCommitFlow() {
return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage }))
return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }))
}
it('openCommitDialog saves pending, refreshes files, then opens dialog', async () => {
@@ -46,6 +48,18 @@ describe('useCommitFlow', () => {
expect(result.current.showCommitDialog).toBe(false)
})
it('handleCommitPush calls onPushRejected when push is rejected', async () => {
commitAndPush.mockResolvedValueOnce({ status: 'rejected', message: 'Push rejected' })
const { result } = renderCommitFlow()
await act(async () => {
await result.current.handleCommitPush('test message')
})
expect(onPushRejected).toHaveBeenCalledTimes(1)
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('push rejected'))
})
it('handleCommitPush shows error toast on failure', async () => {
commitAndPush.mockRejectedValueOnce(new Error('push failed'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

View File

@@ -1,14 +1,16 @@
import { useCallback, useState } from 'react'
import type { GitPushResult } from '../types'
interface CommitFlowConfig {
savePending: () => Promise<void | boolean>
loadModifiedFiles: () => Promise<void>
commitAndPush: (message: string) => Promise<string>
commitAndPush: (message: string) => Promise<GitPushResult>
setToastMessage: (msg: string | null) => void
onPushRejected?: () => void
}
/** Manages the Commit & Push dialog state and the save→commit→push flow. */
export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage }: CommitFlowConfig) {
export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }: CommitFlowConfig) {
const [showCommitDialog, setShowCommitDialog] = useState(false)
const openCommitDialog = useCallback(async () => {
@@ -22,13 +24,20 @@ export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, s
try {
await savePending()
const result = await commitAndPush(message)
setToastMessage(result)
if (result.status === 'ok') {
setToastMessage('Committed and pushed')
} else if (result.status === 'rejected') {
setToastMessage('Committed, but push rejected — remote has new commits. Pull first.')
onPushRejected?.()
} else {
setToastMessage(result.message)
}
loadModifiedFiles()
} catch (err) {
console.error('Commit failed:', err)
setToastMessage(`Commit failed: ${err}`)
}
}, [savePending, commitAndPush, loadModifiedFiles, setToastMessage])
}, [savePending, commitAndPush, loadModifiedFiles, setToastMessage, onPushRejected])
const closeCommitDialog = useCallback(() => setShowCommitDialog(false), [])

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useEditorSave } from './useEditorSave'
@@ -256,6 +256,114 @@ describe('useEditorSave', () => {
)
})
describe('auto-save debounce', () => {
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('auto-saves 500ms after last content change', async () => {
const onNotePersisted = vi.fn()
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted })
)
act(() => {
result.current.handleContentChange('/test/note.md', 'auto-saved content')
})
// Not saved yet
expect(mockInvokeFn).not.toHaveBeenCalled()
// Advance 500ms
await act(async () => { vi.advanceTimersByTime(500) })
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
path: '/test/note.md',
content: 'auto-saved content',
})
expect(onNotePersisted).toHaveBeenCalledWith('/test/note.md', 'auto-saved content')
})
it('resets debounce timer on each content change', async () => {
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
)
act(() => { result.current.handleContentChange('/test/note.md', 'v1') })
// Advance 400ms (not yet 500ms)
await act(async () => { vi.advanceTimersByTime(400) })
expect(mockInvokeFn).not.toHaveBeenCalled()
// New edit resets timer
act(() => { result.current.handleContentChange('/test/note.md', 'v2') })
// Another 400ms (800ms total, but only 400ms from last edit)
await act(async () => { vi.advanceTimersByTime(400) })
expect(mockInvokeFn).not.toHaveBeenCalled()
// 100ms more = 500ms from last edit
await act(async () => { vi.advanceTimersByTime(100) })
expect(mockInvokeFn).toHaveBeenCalledWith('save_note_content', {
path: '/test/note.md',
content: 'v2',
})
})
it('auto-save does not show toast', async () => {
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
)
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
await act(async () => { vi.advanceTimersByTime(500) })
expect(setToastMessage).not.toHaveBeenCalled()
})
it('Cmd+S cancels pending auto-save and saves immediately', async () => {
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
)
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
// Cmd+S before debounce fires
await act(async () => { await result.current.handleSave() })
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
expect(setToastMessage).toHaveBeenCalledWith('Saved')
// Advancing timer should NOT cause a second save
await act(async () => { vi.advanceTimersByTime(500) })
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
})
it('auto-save calls onAfterSave', async () => {
const onAfterSave = vi.fn()
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave })
)
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
await act(async () => { vi.advanceTimersByTime(500) })
expect(onAfterSave).toHaveBeenCalled()
})
it('clears auto-save timer on unmount', async () => {
const { result, unmount } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage })
)
act(() => { result.current.handleContentChange('/test/note.md', 'content') })
unmount()
await act(async () => { vi.advanceTimersByTime(500) })
// Should not save after unmount
expect(mockInvokeFn).not.toHaveBeenCalled()
})
})
it('successive edits and saves persist each version correctly', async () => {
const { result } = renderSaveHook()

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import type { SetStateAction } from 'react'
import { useSaveNote } from './useSaveNote'
@@ -18,13 +18,16 @@ interface EditorSaveConfig {
}
/**
* Hook that manages explicit save (Cmd+S) for editor content.
* Tracks pending (unsaved) content and provides save + pre-rename helpers.
* Hook that manages editor content persistence with auto-save.
* Content is auto-saved 500ms after the last edit. Cmd+S flushes immediately.
*/
const noop = () => {}
const AUTO_SAVE_DEBOUNCE_MS = 500
export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, onAfterSave = noop, onNotePersisted }: EditorSaveConfig) {
const pendingContentRef = useRef<{ path: string; content: string } | null>(null)
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const updateTabAndContent = useCallback((path: string, content: string) => {
updateVaultContent(path, content)
@@ -47,9 +50,22 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
return true
}, [saveNote, onNotePersisted])
// Stable ref for onAfterSave so the auto-save timer closure always calls the latest version
const onAfterSaveRef = useRef(onAfterSave)
useEffect(() => { onAfterSaveRef.current = onAfterSave }, [onAfterSave])
/** Cancel any pending auto-save timer. */
const cancelAutoSave = useCallback(() => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
autoSaveTimerRef.current = null
}
}, [])
/** Called by Cmd+S — persists the current editor content to disk.
* Accepts optional fallback for unsaved notes with no pending edits. */
const handleSave = useCallback(async (unsavedFallback?: { path: string; content: string }) => {
cancelAutoSave()
try {
const saved = await flushPending()
if (!saved && unsavedFallback) {
@@ -65,26 +81,39 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
console.error('Save failed:', err)
setToastMessage(`Save failed: ${err}`)
}
}, [flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
}, [cancelAutoSave, flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
/** Called by Editor onChange — buffers the latest content and syncs tab state
* so consumers (e.g. AI panel) always see current editor content. */
/** Called by Editor onChange — buffers the latest content, syncs tab state,
* and schedules an auto-save after 500ms of inactivity. */
const handleContentChange = useCallback((path: string, content: string) => {
pendingContentRef.current = { path, content }
setTabs((prev: Tab[]) =>
prev.map((t) => t.entry.path === path ? { ...t, content } : t)
)
}, [setTabs])
cancelAutoSave()
autoSaveTimerRef.current = setTimeout(async () => {
autoSaveTimerRef.current = null
try {
const saved = await flushPending()
if (saved) onAfterSaveRef.current()
} catch (err) {
console.error('Auto-save failed:', err)
}
}, AUTO_SAVE_DEBOUNCE_MS)
}, [setTabs, cancelAutoSave, flushPending])
/** Save pending content for a specific path (used before rename) */
// Clear auto-save timer on unmount
useEffect(() => () => cancelAutoSave(), [cancelAutoSave])
/** Save pending content for a specific path (used before rename / tab close) */
const savePendingForPath = useCallback(
(path: string): Promise<boolean> => flushPending(path),
[flushPending],
(path: string): Promise<boolean> => { cancelAutoSave(); return flushPending(path) },
[cancelAutoSave, flushPending],
)
/** Flush any pending content to disk silently (used before git commit).
* Does NOT call onAfterSave — callers manage their own refresh. */
const savePending = useCallback((): Promise<boolean> => flushPending(), [flushPending])
const savePending = useCallback((): Promise<boolean> => { cancelAutoSave(); return flushPending() }, [cancelAutoSave, flushPending])
return { handleSave, handleContentChange, savePendingForPath, savePending }
}

View File

@@ -1,145 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useIndexing } from './useIndexing'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn().mockResolvedValue(vi.fn()) }))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn().mockResolvedValue({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
}),
}))
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
describe('useIndexing', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockInvoke.mockResolvedValue({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
})
})
afterEach(() => {
vi.useRealTimers()
})
it('starts with idle phase', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.progress.phase).toBe('idle')
})
it('auto-dismisses error phase after 15 seconds', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
// Simulate setting error state via retryIndexing
mockInvoke.mockRejectedValueOnce(new Error('qmd update failed'))
await act(async () => { await result.current.retryIndexing() })
expect(result.current.progress.phase).toBe('error')
act(() => { vi.advanceTimersByTime(15000) })
expect(result.current.progress.phase).toBe('idle')
})
it('sets unavailable phase for "not installed" errors', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
mockInvoke.mockRejectedValueOnce(new Error('bun not installed'))
await act(async () => { await result.current.retryIndexing() })
expect(result.current.progress.phase).toBe('unavailable')
})
it('sets unavailable phase for "not available" errors', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
mockInvoke.mockRejectedValueOnce(new Error('qmd not available: bun not found'))
await act(async () => { await result.current.retryIndexing() })
expect(result.current.progress.phase).toBe('unavailable')
})
it('auto-dismisses unavailable phase after 8 seconds', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
mockInvoke.mockRejectedValueOnce(new Error('bun not installed'))
await act(async () => { await result.current.retryIndexing() })
expect(result.current.progress.phase).toBe('unavailable')
act(() => { vi.advanceTimersByTime(8000) })
expect(result.current.progress.phase).toBe('idle')
})
it('exposes retryIndexing function', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(typeof result.current.retryIndexing).toBe('function')
})
it('exposes triggerFullReindex function', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(typeof result.current.triggerFullReindex).toBe('function')
})
it('retryIndexing is the same reference as triggerFullReindex', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.retryIndexing).toBe(result.current.triggerFullReindex)
})
it('triggerFullReindex sets scanning phase then completes', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
await act(async () => { await result.current.triggerFullReindex() })
// In non-Tauri mode, it goes to 'complete' then auto-dismisses
expect(result.current.progress.phase).toBe('complete')
})
it('triggerFullReindex sets lastIndexedTime on success', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.lastIndexedTime).toBeNull()
await act(async () => { await result.current.triggerFullReindex() })
expect(result.current.lastIndexedTime).toBeGreaterThan(0)
})
it('triggerFullReindex sets error phase on failure', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
mockInvoke.mockRejectedValueOnce(new Error('indexing failed'))
await act(async () => { await result.current.triggerFullReindex() })
expect(result.current.progress.phase).toBe('error')
})
it('populates lastIndexedTime from backend metadata on mount', async () => {
mockInvoke.mockResolvedValue({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
last_indexed_commit: 'abc123',
last_indexed_at: 1709800000,
})
const { result } = renderHook(() => useIndexing('/test/vault'))
// Wait for the effect to run
await act(async () => { await vi.advanceTimersByTimeAsync(10) })
expect(result.current.lastIndexedTime).toBe(1709800000000) // seconds → ms
})
it('starts with lastIndexedTime as null', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.lastIndexedTime).toBeNull()
})
})

View File

@@ -1,159 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export interface IndexingProgress {
phase: 'idle' | 'installing' | 'scanning' | 'embedding' | 'complete' | 'error' | 'unavailable'
current: number
total: number
done: boolean
error: string | null
}
interface IndexStatus {
available: boolean
qmd_installed: boolean
collection_exists: boolean
indexed_count: number
embedded_count: number
pending_embed: number
last_indexed_commit: string | null
last_indexed_at: number | null
}
const IDLE: IndexingProgress = { phase: 'idle', current: 0, total: 0, done: false, error: null }
function invokeCmd<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
}
export function useIndexing(vaultPath: string) {
const [progress, setProgress] = useState<IndexingProgress>(IDLE)
const [lastIndexedTime, setLastIndexedTime] = useState<number | null>(null)
const indexingRef = useRef(false)
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
// Listen for progress events from Rust
useEffect(() => {
if (!isTauri()) return
let cleanup: (() => void) | null = null
import('@tauri-apps/api/event').then(({ listen }) => {
const unlisten = listen<IndexingProgress>('indexing-progress', (event) => {
setProgress(event.payload)
if (event.payload.done) {
indexingRef.current = false
if (event.payload.phase === 'complete') {
setLastIndexedTime(Date.now())
}
}
})
cleanup = () => { unlisten.then(fn => fn()) }
})
return () => { cleanup?.() }
}, [])
// Check index status and auto-trigger indexing on vault open
useEffect(() => {
if (!vaultPath) return
let cancelled = false
async function checkAndIndex() {
try {
const status = await invokeCmd<IndexStatus>('get_index_status', { vaultPath })
if (cancelled) return
// Populate last indexed time from backend metadata
if (status.last_indexed_at) {
setLastIndexedTime(status.last_indexed_at * 1000) // seconds → ms
}
// If qmd not installed or no collection or pending embeds, trigger indexing
const needsIndexing = !status.qmd_installed || !status.collection_exists || status.pending_embed > 0
if (needsIndexing && !indexingRef.current) {
indexingRef.current = true
setProgress({
phase: status.qmd_installed ? 'scanning' : 'installing',
current: 0,
total: status.indexed_count,
done: false,
error: null,
})
// Fire and forget — progress updates come via events
invokeCmd('start_indexing', { vaultPath }).catch((err) => {
if (cancelled) return
const msg = String(err)
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
setProgress({
phase: isUnavailable ? 'unavailable' : 'error',
current: 0,
total: 0,
done: true,
error: msg,
})
indexingRef.current = false
})
}
} catch {
// get_index_status failed — likely qmd not available, non-fatal
}
}
checkAndIndex()
return () => { cancelled = true }
}, [vaultPath])
// Auto-dismiss transient statuses after a delay
useEffect(() => {
if (progress.phase === 'complete') {
const timer = setTimeout(() => setProgress(IDLE), 5000)
return () => clearTimeout(timer)
}
if (progress.phase === 'unavailable') {
const timer = setTimeout(() => setProgress(IDLE), 8000)
return () => clearTimeout(timer)
}
if (progress.phase === 'error') {
const timer = setTimeout(() => setProgress(IDLE), 15000)
return () => clearTimeout(timer)
}
}, [progress.phase])
const triggerIncrementalIndex = useCallback(async () => {
if (indexingRef.current) return
try {
await invokeCmd('trigger_incremental_index', { vaultPath: vaultPathRef.current })
setLastIndexedTime(Date.now())
} catch {
// Incremental update failure is non-fatal
}
}, [])
const triggerFullReindex = useCallback(async () => {
if (indexingRef.current || !vaultPathRef.current) return
indexingRef.current = true
setProgress({ phase: 'scanning', current: 0, total: 0, done: false, error: null })
try {
await invokeCmd('start_indexing', { vaultPath: vaultPathRef.current })
// In non-Tauri mode, mark complete immediately
if (!isTauri()) {
setProgress({ phase: 'complete', current: 0, total: 0, done: true, error: null })
setLastIndexedTime(Date.now())
}
} catch (err) {
const msg = String(err)
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
setProgress({ phase: isUnavailable ? 'unavailable' : 'error', current: 0, total: 0, done: true, error: msg })
} finally {
indexingRef.current = false
}
}, [])
const retryIndexing = triggerFullReindex
return { progress, lastIndexedTime, triggerIncrementalIndex, triggerFullReindex, retryIndexing }
}

View File

@@ -28,15 +28,14 @@ function makeHandlers(): MenuEventHandlers {
onOpenVault: vi.fn(),
onRemoveActiveVault: vi.fn(),
onRestoreGettingStarted: vi.fn(),
onCreateTheme: vi.fn(),
onRestoreDefaultThemes: vi.fn(),
onCommitPush: vi.fn(),
onPull: vi.fn(),
onResolveConflicts: vi.fn(),
onViewChanges: vi.fn(),
onInstallMcp: vi.fn(),
onReindexVault: vi.fn(),
onReloadVault: vi.fn(),
onReopenClosedTab: vi.fn(),
onOpenInNewWindow: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
activeTabPath: '/vault/test.md',
@@ -266,24 +265,18 @@ describe('dispatchMenuEvent', () => {
expect(h.onRestoreGettingStarted).toHaveBeenCalled()
})
it('vault-new-theme triggers create theme', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-new-theme', h)
expect(h.onCreateTheme).toHaveBeenCalled()
})
it('vault-restore-default-themes triggers restore default themes', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-restore-default-themes', h)
expect(h.onRestoreDefaultThemes).toHaveBeenCalled()
})
it('vault-commit-push triggers commit push', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-commit-push', h)
expect(h.onCommitPush).toHaveBeenCalled()
})
it('vault-pull triggers pull', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-pull', h)
expect(h.onPull).toHaveBeenCalled()
})
it('vault-resolve-conflicts triggers resolve conflicts', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-resolve-conflicts', h)
@@ -302,12 +295,6 @@ describe('dispatchMenuEvent', () => {
expect(h.onInstallMcp).toHaveBeenCalled()
})
it('vault-reindex triggers reindex vault', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-reindex', h)
expect(h.onReindexVault).toHaveBeenCalled()
})
it('vault-reload triggers reload vault', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-reload', h)
@@ -321,6 +308,13 @@ describe('dispatchMenuEvent', () => {
expect(h.onReopenClosedTab).toHaveBeenCalled()
})
// Note: open in new window
it('note-open-in-new-window triggers open in new window', () => {
const h = makeHandlers()
dispatchMenuEvent('note-open-in-new-window', h)
expect(h.onOpenInNewWindow).toHaveBeenCalled()
})
// Edge cases
it('unknown event ID does nothing', () => {
const h = makeHandlers()

View File

@@ -29,14 +29,13 @@ export interface MenuEventHandlers {
onOpenVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onCreateTheme?: () => void
onRestoreDefaultThemes?: () => void
onCommitPush?: () => void
onPull?: () => void
onResolveConflicts?: () => void
onViewChanges?: () => void
onInstallMcp?: () => void
onReopenClosedTab?: () => void
onReindexVault?: () => void
onOpenInNewWindow?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onEmptyTrash?: () => void
@@ -75,16 +74,17 @@ const FILTER_MAP: Record<string, SidebarFilter> = {
'go-archived': 'archived',
'go-trash': 'trash',
'go-changes': 'changes',
'go-inbox': 'inbox',
}
type OptionalHandler =
| 'onGoBack' | 'onGoForward' | 'onCheckForUpdates'
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
| 'onCreateTheme' | 'onRestoreDefaultThemes'
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'
| 'onEmptyTrash'
| 'onReopenClosedTab'
| 'onOpenInNewWindow'
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'view-go-back': 'onGoBack',
@@ -97,17 +97,16 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'vault-open': 'onOpenVault',
'vault-remove': 'onRemoveActiveVault',
'vault-restore-getting-started': 'onRestoreGettingStarted',
'vault-new-theme': 'onCreateTheme',
'vault-restore-default-themes': 'onRestoreDefaultThemes',
'vault-commit-push': 'onCommitPush',
'vault-pull': 'onPull',
'vault-resolve-conflicts': 'onResolveConflicts',
'vault-view-changes': 'onViewChanges',
'vault-install-mcp': 'onInstallMcp',
'vault-reindex': 'onReindexVault',
'vault-reload': 'onReloadVault',
'vault-repair': 'onRepairVault',
'note-empty-trash': 'onEmptyTrash',
'file-reopen-closed-tab': 'onReopenClosedTab',
'note-open-in-new-window': 'onOpenInNewWindow',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {

View File

@@ -19,7 +19,7 @@ import {
resolveTemplate,
} from './useNoteCreation'
import { needsRenameOnSave } from './useNoteRename'
import { frontmatterToEntryPatch } from './frontmatterOps'
import { frontmatterToEntryPatch, applyRelationshipPatch } from './frontmatterOps'
import { useNoteActions } from './useNoteActions'
import type { NoteActionsConfig } from './useNoteActions'
@@ -345,25 +345,45 @@ describe('frontmatterToEntryPatch', () => {
] as [string, unknown, Partial<VaultEntry>][])(
'maps %s update to correct entry field',
(key, value, expected) => {
expect(frontmatterToEntryPatch('update', key, value as never)).toEqual(expected)
expect(frontmatterToEntryPatch('update', key, value as never).patch).toEqual(expected)
},
)
it('maps aliases update with array value', () => {
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B'])).toEqual({ aliases: ['A', 'B'] })
expect(frontmatterToEntryPatch('update', 'aliases', ['A', 'B']).patch).toEqual({ aliases: ['A', 'B'] })
})
it('maps belongs_to update with array value', () => {
expect(frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])).toEqual({ belongsTo: ['[[parent]]'] })
const result = frontmatterToEntryPatch('update', 'belongs_to', ['[[parent]]'])
expect(result.patch).toEqual({ belongsTo: ['[[parent]]'] })
// Also produces a relationship patch for the wikilink
expect(result.relationshipPatch).toEqual({ 'belongs_to': ['[[parent]]'] })
})
it('handles case-insensitive keys with spaces (e.g. "Is A", "Belongs to")', () => {
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment')).toEqual({ isA: 'Experiment' })
expect(frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])).toEqual({ belongsTo: ['[[x]]'] })
expect(frontmatterToEntryPatch('update', 'Is A', 'Experiment').patch).toEqual({ isA: 'Experiment' })
const result = frontmatterToEntryPatch('update', 'Belongs to', ['[[x]]'])
expect(result.patch).toEqual({ belongsTo: ['[[x]]'] })
expect(result.relationshipPatch).toEqual({ 'Belongs to': ['[[x]]'] })
})
it('returns empty object for unknown keys', () => {
expect(frontmatterToEntryPatch('update', 'custom_field', 'value')).toEqual({})
it('returns empty patch for unknown non-wikilink keys', () => {
const result = frontmatterToEntryPatch('update', 'custom_field', 'value')
expect(result.patch).toEqual({})
// Non-wikilink value → no relationship change
expect(result.relationshipPatch).toBeNull()
})
it('produces relationship patch for wikilink values on unknown keys', () => {
const result = frontmatterToEntryPatch('update', 'Notes', ['[[note-a]]', '[[note-b|Note B]]'])
expect(result.patch).toEqual({})
expect(result.relationshipPatch).toEqual({ Notes: ['[[note-a]]', '[[note-b|Note B]]'] })
})
it('produces relationship patch for single wikilink string', () => {
const result = frontmatterToEntryPatch('update', 'Owner', '[[person/alice]]')
expect(result.patch).toEqual({})
expect(result.relationshipPatch).toEqual({ Owner: ['[[person/alice]]'] })
})
it.each([
@@ -377,12 +397,46 @@ describe('frontmatterToEntryPatch', () => {
] as [string, Partial<VaultEntry>][])(
'maps delete of %s to null/default',
(key, expected) => {
expect(frontmatterToEntryPatch('delete', key)).toEqual(expected)
expect(frontmatterToEntryPatch('delete', key).patch).toEqual(expected)
},
)
it('returns empty object for unknown key on delete', () => {
expect(frontmatterToEntryPatch('delete', 'unknown_key')).toEqual({})
it('returns empty patch for unknown key on delete, with relationship removal', () => {
const result = frontmatterToEntryPatch('delete', 'unknown_key')
expect(result.patch).toEqual({})
expect(result.relationshipPatch).toEqual({ unknown_key: null })
})
it('delete of known key also produces relationship removal', () => {
const result = frontmatterToEntryPatch('delete', 'status')
expect(result.patch).toEqual({ status: null })
expect(result.relationshipPatch).toEqual({ status: null })
})
})
describe('applyRelationshipPatch', () => {
it('adds new relationship key', () => {
const existing = { 'Belongs to': ['[[eng]]'] }
const result = applyRelationshipPatch(existing, { Notes: ['[[note-a]]', '[[note-b]]'] })
expect(result).toEqual({ 'Belongs to': ['[[eng]]'], Notes: ['[[note-a]]', '[[note-b]]'] })
})
it('overwrites existing relationship key', () => {
const existing = { Notes: ['[[old]]'] }
const result = applyRelationshipPatch(existing, { Notes: ['[[new-a]]', '[[new-b]]'] })
expect(result).toEqual({ Notes: ['[[new-a]]', '[[new-b]]'] })
})
it('removes relationship key when value is null', () => {
const existing = { Notes: ['[[a]]'], Owner: ['[[alice]]'] }
const result = applyRelationshipPatch(existing, { Notes: null })
expect(result).toEqual({ Owner: ['[[alice]]'] })
})
it('does not mutate the original map', () => {
const existing = { Notes: ['[[a]]'] }
applyRelationshipPatch(existing, { Notes: ['[[b]]'] })
expect(existing).toEqual({ Notes: ['[[a]]'] })
})
})

View File

@@ -117,8 +117,8 @@ export function useNoteActions(config: NoteActionsConfig) {
const runFrontmatterOp = useCallback(
(op: 'update' | 'delete', path: string, key: string, value?: FrontmatterValue, options?: FrontmatterOpOptions) =>
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage }, options),
[updateTabContent, updateEntry, setToastMessage],
runFrontmatterAndApply(op, path, key, value, { updateTab: updateTabContent, updateEntry, toast: setToastMessage, getEntry: (p) => entries.find((e) => e.path === p) }, options),
[updateTabContent, updateEntry, setToastMessage, entries],
)
return {

View File

@@ -1,582 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import type { VaultEntry } from '../types'
const THEME_PATH_DEFAULT = '/vault/theme/default.md'
const THEME_PATH_DARK = '/vault/theme/dark.md'
const DEFAULT_THEME_CONTENT = `---
type: Theme
Description: Light theme
background: "#FFFFFF"
foreground: "#37352F"
primary: "#155DFF"
sidebar: "#F7F6F3"
text-primary: "#37352F"
---
# Default Theme
`
const DARK_THEME_CONTENT = `---
type: Theme
Description: Dark theme
background: "#0f0f1a"
foreground: "#e0e0e0"
primary: "#155DFF"
sidebar: "#1a1a2e"
text-primary: "#e0e0e0"
---
# Dark Theme
`
function makeThemeEntry(path: string, title: string): VaultEntry {
return {
path,
filename: path.split('/').pop()!,
title,
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: null,
createdAt: null,
fileSize: 0,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}
}
const defaultEntry = makeThemeEntry(THEME_PATH_DEFAULT, 'Default Theme')
const darkEntry = makeThemeEntry(THEME_PATH_DARK, 'Dark Theme')
const mockInvokeFn = vi.fn(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT }
if (cmd === 'get_note_content') {
const path = args?.path as string | undefined
if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT
if (path === THEME_PATH_DARK) return DARK_THEME_CONTENT
return ''
}
if (cmd === 'set_active_theme') return null
if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md'
return null
})
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
const { useThemeManager, extractCssVars, isColorDark } = await import('./useThemeManager')
describe('extractCssVars', () => {
it('extracts color variables from frontmatter', () => {
const vars = extractCssVars(DEFAULT_THEME_CONTENT)
expect(vars['--background']).toBe('#FFFFFF')
expect(vars['--foreground']).toBe('#37352F')
expect(vars['--primary']).toBe('#155DFF')
})
it('excludes metadata keys', () => {
const vars = extractCssVars(DEFAULT_THEME_CONTENT)
expect('--Is A' in vars).toBe(false)
expect('--Description' in vars).toBe(false)
})
})
describe('isColorDark', () => {
it('identifies dark colors', () => {
expect(isColorDark('#000000')).toBe(true)
expect(isColorDark('#0f0f1a')).toBe(true)
expect(isColorDark('#1a1a2e')).toBe(true)
})
it('identifies light colors', () => {
expect(isColorDark('#FFFFFF')).toBe(false)
expect(isColorDark('#F7F6F3')).toBe(false)
expect(isColorDark('#E0E0E0')).toBe(false)
})
it('returns false for invalid hex', () => {
expect(isColorDark('')).toBe(false)
expect(isColorDark('#abc')).toBe(false)
expect(isColorDark('red')).toBe(false)
})
})
describe('useThemeManager', () => {
const entries = [defaultEntry, darkEntry]
const allContent: Record<string, string> = {}
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT }
if (cmd === 'get_note_content') {
const path = args?.path as string | undefined
if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT
if (path === THEME_PATH_DARK) return DARK_THEME_CONTENT
return ''
}
if (cmd === 'set_active_theme') return null
if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md'
return null
})
document.documentElement.style.cssText = ''
})
it('builds themes list from vault entries with isA === Theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
expect(result.current.themes[0].name).toBe('Default Theme')
expect(result.current.themes[0].id).toBe(THEME_PATH_DEFAULT)
})
it('loads active theme from vault settings on mount', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
expect(result.current.activeTheme?.name).toBe('Default Theme')
})
it('applies CSS vars from theme note content', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--foreground')).toBe('#37352F')
expect(root.style.getPropertyValue('--primary')).toBe('#155DFF')
})
it('returns empty state when vaultPath is null', async () => {
const { result } = renderHook(() =>
useThemeManager(null, entries, allContent)
)
await new Promise(r => setTimeout(r, 50))
expect(result.current.activeThemeId).toBeNull()
expect(result.current.activeTheme).toBeNull()
expect(mockInvokeFn).not.toHaveBeenCalled()
})
it('excludes trashed entries from themes list', async () => {
const trashedEntry = { ...darkEntry, trashed: true }
const { result } = renderHook(() =>
useThemeManager('/vault', [defaultEntry, trashedEntry], allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(1)
})
expect(result.current.themes[0].name).toBe('Default Theme')
})
it('switchTheme calls set_active_theme and updates activeThemeId', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
await act(async () => {
await result.current.switchTheme(THEME_PATH_DARK)
})
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', {
vaultPath: '/vault', themeId: THEME_PATH_DARK,
})
expect(result.current.activeThemeId).toBe(THEME_PATH_DARK)
})
it('clears old CSS vars and applies new theme on switch', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
})
await act(async () => {
await result.current.switchTheme(THEME_PATH_DARK)
})
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#0f0f1a')
})
})
it('createTheme calls create_vault_theme and switches to new theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
let newPath = ''
await act(async () => {
newPath = await result.current.createTheme('My Theme')
})
expect(newPath).toBe('/vault/theme/untitled.md')
expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', {
vaultPath: '/vault', name: 'My Theme',
})
expect(result.current.activeThemeId).toBe('/vault/theme/untitled.md')
})
it('createTheme passes null name when none provided', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
await act(async () => {
await result.current.createTheme()
})
expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', {
vaultPath: '/vault', name: null,
})
})
it('falls back when active theme is trashed', async () => {
const { result, rerender } = renderHook(
({ ents }) => useThemeManager('/vault', ents, allContent),
{ initialProps: { ents: entries } },
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
const trashedDefault = { ...defaultEntry, trashed: true }
rerender({ ents: [trashedDefault, darkEntry] })
await waitFor(() => {
expect(result.current.activeThemeId).toBeNull()
})
// CSS vars are cleared from tracked applied vars — DOM state depends on prior apply
})
it('handles load failure gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockRejectedValue(new Error('disk error'))
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await new Promise(r => setTimeout(r, 50))
expect(result.current.activeThemeId).toBeNull()
warnSpy.mockRestore()
})
it('handles switchTheme failure gracefully', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
mockInvokeFn.mockRejectedValueOnce(new Error('permission denied'))
await act(async () => {
await result.current.switchTheme(THEME_PATH_DARK)
})
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
errorSpy.mockRestore()
})
it('switchTheme is a no-op when vaultPath is null', async () => {
const { result } = renderHook(() =>
useThemeManager(null, entries, allContent)
)
await act(async () => {
await result.current.switchTheme(THEME_PATH_DARK)
})
expect(mockInvokeFn).not.toHaveBeenCalledWith('set_active_theme', expect.anything())
})
it('createTheme returns empty string when vaultPath is null', async () => {
const { result } = renderHook(() =>
useThemeManager(null, entries, allContent)
)
let newPath = ''
await act(async () => {
newPath = await result.current.createTheme()
})
expect(newPath).toBe('')
})
it('reloadThemes re-reads vault settings', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
const initialCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length
await act(async () => {
await result.current.reloadThemes()
})
const afterCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length
expect(afterCalls).toBe(initialCalls + 1)
})
it('clears stale theme ID that does not match any known theme', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_vault_settings') return { theme: 'untitled-2' }
if (cmd === 'set_active_theme') return null
if (cmd === 'ensure_vault_themes') return null
return null
})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
// Stale ID "untitled-2" doesn't match any theme path — should be cleared
await waitFor(() => {
expect(result.current.activeThemeId).toBeNull()
})
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', {
vaultPath: '/vault', themeId: null,
})
})
it('sets color-scheme to light for light theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
expect(root.dataset.themeMode).toBe('light')
})
it('sets color-scheme to dark for dark theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.switchTheme(THEME_PATH_DARK)
})
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark')
})
expect(document.documentElement.dataset.themeMode).toBe('dark')
})
it('isDark detects dark theme from cached content', async () => {
const contentWithColors = {
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
[THEME_PATH_DARK]: DARK_THEME_CONTENT,
}
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DARK }
if (cmd === 'set_active_theme') return null
if (cmd === 'get_note_content') return DARK_THEME_CONTENT
return null
})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, contentWithColors)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DARK)
})
await waitFor(() => {
expect(result.current.isDark).toBe(true)
})
})
it('isDark is false for light theme', async () => {
const contentWithColors = {
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
}
const { result } = renderHook(() =>
useThemeManager('/vault', entries, contentWithColors)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
// Light theme isDark should be false (default state is false, so this is stable)
expect(result.current.isDark).toBe(false)
})
it('notifyThemeSaved updates CSS vars when active theme is saved', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
const updatedContent = `---
type: Theme
Description: Light theme
background: "#1a1a2e"
foreground: "#e0e0e0"
primary: "#155DFF"
sidebar: "#2a2a3e"
text-primary: "#e0e0e0"
---
# Default Theme
`
act(() => {
result.current.notifyThemeSaved(THEME_PATH_DEFAULT, updatedContent)
})
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#1a1a2e')
})
expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#2a2a3e')
})
it('notifyThemeSaved updates bullet-size and bullet-color CSS vars', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
const updatedContent = `---
type: Theme
Description: Light theme
background: "#FFFFFF"
foreground: "#37352F"
primary: "#155DFF"
sidebar: "#F7F6F3"
text-primary: "#37352F"
lists-bullet-size: 32px
lists-bullet-color: "#FF0000"
---
# Default Theme
`
act(() => {
result.current.notifyThemeSaved(THEME_PATH_DEFAULT, updatedContent)
})
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--lists-bullet-size')).toBe('32px')
})
expect(document.documentElement.style.getPropertyValue('--lists-bullet-color')).toBe('#FF0000')
})
it('notifyThemeSaved is a no-op for non-active theme path', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
act(() => {
result.current.notifyThemeSaved(THEME_PATH_DARK, DARK_THEME_CONTENT)
})
// Background should still be the default theme's white, not dark
await new Promise(r => setTimeout(r, 50))
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
})
it('stale async fetch does not overwrite live-reload content', async () => {
// Simulate a slow get_note_content that resolves AFTER notifyThemeSaved
let resolveSlowFetch!: (v: string) => void
let fetchCount = 0
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT }
if (cmd === 'get_note_content') {
fetchCount++
if (fetchCount === 1) {
// First fetch: return a pending promise (simulates slow disk)
return new Promise<string>(r => { resolveSlowFetch = r })
}
const path = args?.path as string | undefined
if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT
return ''
}
if (cmd === 'set_active_theme') return null
return null
})
const { result } = renderHook(() => useThemeManager('/vault', entries, allContent))
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
// Before slow fetch resolves, user saves the theme note (live-reload via Cmd+S)
const updatedContent = `---
type: Theme
background: "#FF0000"
---
`
act(() => {
result.current.notifyThemeSaved(THEME_PATH_DEFAULT, updatedContent)
})
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000')
})
// Now the stale fetch resolves with old content — should be ignored
resolveSlowFetch(DEFAULT_THEME_CONTENT)
await new Promise(r => setTimeout(r, 50))
// Background should still be the live-reload value, not the stale fetch
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000')
})
it('calls ensure_vault_themes on mount with vaultPath', async () => {
renderHook(() => useThemeManager('/vault', entries, allContent))
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('ensure_vault_themes', { vaultPath: '/vault' })
})
})
it('does not call ensure_vault_themes when vaultPath is null', async () => {
renderHook(() => useThemeManager(null, entries, allContent))
await new Promise(r => setTimeout(r, 50))
expect(mockInvokeFn).not.toHaveBeenCalledWith('ensure_vault_themes', expect.anything())
})
})

View File

@@ -1,310 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { parseFrontmatter } from '../utils/frontmatter'
import type { ThemeFile, VaultEntry, VaultSettings } from '../types'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
/** Frontmatter keys that are metadata — not CSS custom properties. */
const NON_THEME_KEYS = new Set([
'Is A', 'type', 'is_a', 'is a',
'Name', 'name', 'title', 'Title',
'Description', 'description',
'Archived', 'archived',
'Trashed', 'trashed',
'Trashed at', 'trashed at', 'trashed_at',
'Created at', 'created at', 'created_at',
'Created time', 'created_time',
'Owner', 'owner',
'Status', 'status',
'Cadence', 'cadence',
'aliases',
'Belongs to', 'belongs_to', 'belongs to',
'Related to', 'related_to', 'related to',
])
/** Extract CSS custom properties from a theme note's frontmatter content. */
export function extractCssVars(content: string): Record<string, string> {
const fm = parseFrontmatter(content)
const vars: Record<string, string> = {}
for (const [key, value] of Object.entries(fm)) {
if (NON_THEME_KEYS.has(key)) continue
if (typeof value === 'string' && value) {
vars[`--${key}`] = value
} else if (typeof value === 'number') {
vars[`--${key}`] = String(value)
}
}
return vars
}
/** Extract bare colors (without -- prefix) for ThemeFile.colors from content. */
function extractColorsFromContent(content: string): Record<string, string> {
const fm = parseFrontmatter(content)
const colors: Record<string, string> = {}
for (const [key, value] of Object.entries(fm)) {
if (NON_THEME_KEYS.has(key)) continue
if (typeof value === 'string' && value.startsWith('#')) {
colors[key] = value
}
}
return colors
}
/** Check if a hex color is perceptually dark (luminance < 0.5). */
export function isColorDark(hex: string): boolean {
if (!hex.startsWith('#') || hex.length < 7) return false
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
}
/** Update color-scheme and data-theme-mode on document root based on --background. */
function updateColorScheme(vars: Record<string, string>): void {
const bg = vars['--background']
if (!bg) return
const dark = isColorDark(bg)
const root = document.documentElement
root.style.setProperty('color-scheme', dark ? 'dark' : 'light')
root.dataset.themeMode = dark ? 'dark' : 'light'
}
function clearColorScheme(): void {
const root = document.documentElement
root.style.removeProperty('color-scheme')
delete root.dataset.themeMode
}
function applyVarsToDom(vars: Record<string, string>): void {
const root = document.documentElement
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value)
}
updateColorScheme(vars)
// Force WebKit to recalculate ::before/::after pseudo-element styles
// when CSS custom properties change (WKWebView doesn't auto-invalidate).
void root.offsetHeight
}
function clearVarsFromDom(vars: Record<string, string>): void {
const root = document.documentElement
for (const key of Object.keys(vars)) {
root.style.removeProperty(key)
}
clearColorScheme()
}
/** Build a ThemeFile descriptor from a vault entry, enriched with content colors. */
function entryToThemeFile(entry: VaultEntry, content: string | undefined): ThemeFile {
return {
id: entry.path,
name: entry.title,
description: '',
path: entry.path,
colors: content ? extractColorsFromContent(content) : {},
typography: {},
spacing: {},
}
}
/** True when a theme entry should no longer be applied (trashed or archived). */
function isEntryRemoved(entry: VaultEntry): boolean {
return entry.trashed || entry.archived
}
export interface ThemeManager {
themes: ThemeFile[]
activeThemeId: string | null
activeTheme: ThemeFile | null
activeThemeContent: string | undefined
isDark: boolean
switchTheme: (themeId: string) => Promise<void>
createTheme: (name?: string) => Promise<string>
reloadThemes: () => Promise<void>
/** Update a single frontmatter property on the active theme note. */
updateThemeProperty: (key: string, value: string) => Promise<void>
/** Notify that the active theme note was saved with new content (live-reload on Cmd+S). */
notifyThemeSaved: (path: string, content: string) => void
}
/** Manages loading and persisting the active theme path from vault settings. */
function useThemeSetting(vaultPath: string | null) {
const [activeThemeId, setActiveThemeId] = useState<string | null>(null)
const load = useCallback(async () => {
if (!vaultPath) return
try {
const s = await tauriCall<VaultSettings>('get_vault_settings', { vaultPath })
setActiveThemeId(s.theme)
} catch { /* no settings file — fine, no active theme */ }
}, [vaultPath])
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fn; setState runs after await
useEffect(() => { load() }, [load])
useEffect(() => {
window.addEventListener('focus', load)
return () => window.removeEventListener('focus', load)
}, [load])
return { activeThemeId, setActiveThemeId, reload: load }
}
/** Applies CSS custom properties to the document root from the active theme. */
function useThemeApplier(
activeThemeId: string | null,
cachedContent: string | undefined,
) {
const appliedVarsRef = useRef<Record<string, string>>({})
const [isDark, setIsDark] = useState(false)
const versionRef = useRef(0)
const applyDom = useCallback((content: string) => {
const newVars = extractCssVars(content)
clearVarsFromDom(appliedVarsRef.current)
applyVarsToDom(newVars)
appliedVarsRef.current = newVars
return newVars
}, [])
const clearDom = useCallback(() => {
clearVarsFromDom(appliedVarsRef.current)
appliedVarsRef.current = {}
}, [])
// Apply theme when activeThemeId or cached content changes.
// Also serves as live-preview: re-applies when the user saves the theme note.
useEffect(() => {
const version = ++versionRef.current
if (!activeThemeId) {
clearDom()
setIsDark(false) // eslint-disable-line react-hooks/set-state-in-effect -- sync dark mode with cleared theme
return
}
if (cachedContent) {
const vars = applyDom(cachedContent)
setIsDark(isColorDark(vars['--background'] ?? ''))
return
}
tauriCall<string>('get_note_content', { path: activeThemeId })
.then(content => {
if (versionRef.current !== version) return
const vars = applyDom(content)
setIsDark(isColorDark(vars['--background'] ?? ''))
})
.catch(() => {
if (versionRef.current !== version) return
clearDom(); setIsDark(false)
})
}, [activeThemeId, cachedContent, applyDom, clearDom])
return { clearDom, isDark }
}
export function useThemeManager(
vaultPath: string | null,
entries: VaultEntry[],
): ThemeManager {
// Ensure default theme files exist on vault open (creates theme/ dir + defaults if missing)
useEffect(() => {
if (vaultPath) tauriCall('ensure_vault_themes', { vaultPath }).catch(() => {})
}, [vaultPath])
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
const [cachedThemeContent, setCachedThemeContent] = useState<string | undefined>(undefined)
// Clear cached content when theme changes — useThemeApplier will fetch from disk
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setCachedThemeContent(undefined) }, [activeThemeId])
const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent)
// Track IDs set by user actions (switchTheme/createTheme) so the stale-ID
// cleanup doesn't clear a newly-created theme that isn't in entries yet.
const userSetIdRef = useRef<string | null>(null)
const themes = useMemo(
() => entries
.filter(e => e.isA === 'Theme' && !e.trashed && !e.archived)
.map(e => entryToThemeFile(e, e.path === activeThemeId ? cachedThemeContent : undefined)),
[entries, activeThemeId, cachedThemeContent],
)
const activeTheme = useMemo(
() => themes.find(t => t.id === activeThemeId) ?? null,
[themes, activeThemeId],
)
// If active theme ID doesn't match any known theme (e.g. stale ID from old
// JSON-based theme system), clear it so the app doesn't try to load a
// non-existent path. Skip IDs just set by switchTheme/createTheme — the
// entry may not have appeared in `entries` yet.
useEffect(() => {
if (!activeThemeId || themes.length === 0) return
if (activeThemeId === userSetIdRef.current) return
const isKnown = themes.some(t => t.id === activeThemeId)
if (!isKnown) {
clearTheme()
setActiveThemeId(null)
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
}
}, [activeThemeId, themes, clearTheme, vaultPath, setActiveThemeId])
// If active theme is trashed or archived: clear CSS vars and fall back to no theme
useEffect(() => {
if (!activeThemeId) return
const entry = entries.find(e => e.path === activeThemeId)
if (!entry || !isEntryRemoved(entry)) return
clearTheme()
setActiveThemeId(null)
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
}, [entries, activeThemeId, clearTheme, vaultPath, setActiveThemeId])
const switchTheme = useCallback(async (themeId: string) => {
if (!vaultPath) return
try {
await tauriCall<null>('set_active_theme', { vaultPath, themeId })
userSetIdRef.current = themeId
setActiveThemeId(themeId)
} catch (err) { console.error('Failed to switch theme:', err) }
}, [vaultPath, setActiveThemeId])
const createTheme = useCallback(async (name?: string) => {
if (!vaultPath) return ''
try {
const path = await tauriCall<string>('create_vault_theme', { vaultPath, name: name ?? null })
await tauriCall<null>('set_active_theme', { vaultPath, themeId: path })
userSetIdRef.current = path
setActiveThemeId(path)
return path
} catch (err) { console.error('Failed to create theme:', err); return '' }
}, [vaultPath, setActiveThemeId])
const reloadThemes = useCallback(async () => { await reload() }, [reload])
const notifyThemeSaved = useCallback((path: string, content: string) => {
if (path === activeThemeId) setCachedThemeContent(content)
}, [activeThemeId])
const updateThemeProperty = useCallback(async (key: string, value: string) => {
if (!activeThemeId) return
try {
const newContent = await tauriCall<string>('update_frontmatter', {
path: activeThemeId,
key,
value,
})
setCachedThemeContent(newContent)
} catch (err) { console.error('Failed to update theme property:', err) }
}, [activeThemeId])
return {
themes, activeThemeId, activeTheme,
activeThemeContent: cachedThemeContent,
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty, notifyThemeSaved,
}
}

View File

@@ -17,7 +17,6 @@ interface SearchResponseData {
}
const DEBOUNCE_MS = 300
const HYBRID_TIMEOUT_MS = 5000
function searchCall(args: Record<string, unknown>): Promise<SearchResponseData> {
return isTauri()
@@ -25,16 +24,6 @@ function searchCall(args: Record<string, unknown>): Promise<SearchResponseData>
: mockInvoke<SearchResponseData>('search_vault', args)
}
function searchWithTimeout(args: Record<string, unknown>, ms: number): Promise<SearchResponseData> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Search timeout')), ms)
searchCall(args).then(
result => { clearTimeout(timer); resolve(result) },
err => { clearTimeout(timer); reject(err) },
)
})
}
function mapResults(raw: SearchResultData[]): SearchResult[] {
const seen = new Set<string>()
return raw
@@ -92,19 +81,7 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) {
setSelectedIndex(0)
} catch {
if (gen !== searchGenRef.current) return
setLoading(false)
return
}
try {
const response = await searchWithTimeout(
{ vaultPath, query: q, mode: 'hybrid', limit: 20 },
HYBRID_TIMEOUT_MS,
)
if (gen !== searchGenRef.current) return
setResults(mapResults(response.results))
setElapsedMs(response.elapsed_ms)
setSelectedIndex(prev => Math.min(prev, Math.max(response.results.length - 1, 0)))
} catch { /* Hybrid timed out — keyword results remain */ } finally {
} finally {
if (gen === searchGenRef.current) setLoading(false)
}
}, [vaultPath])

View File

@@ -1,6 +1,4 @@
import { useEffect, useCallback, useSyncExternalStore } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultConfig } from '../types'
import { initStatusColors } from '../utils/statusStyles'
import { initTagColors } from '../utils/tagStyles'
@@ -14,8 +12,32 @@ import {
} from '../utils/vaultConfigStore'
import { migrateLocalStorageToVaultConfig } from '../utils/configMigration'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
const STORAGE_PREFIX = 'laputa:vault-config:'
function storageKey(vaultPath: string): string {
return `${STORAGE_PREFIX}${vaultPath}`
}
function loadFromStorage(vaultPath: string): VaultConfig {
const DEFAULT: VaultConfig = {
zoom: null, view_mode: null, editor_mode: null,
tag_colors: null, status_colors: null, property_display_modes: null,
}
try {
const raw = localStorage.getItem(storageKey(vaultPath))
if (!raw) return DEFAULT
return { ...DEFAULT, ...JSON.parse(raw) }
} catch {
return DEFAULT
}
}
function saveToStorage(vaultPath: string, config: VaultConfig): void {
try {
localStorage.setItem(storageKey(vaultPath), JSON.stringify(config))
} catch (err) {
console.warn('Failed to save vault config:', err)
}
}
function applyToModules(c: VaultConfig): void {
@@ -24,34 +46,18 @@ function applyToModules(c: VaultConfig): void {
initDisplayModeOverrides(c.property_display_modes ?? {})
}
function persistConfig(vaultPath: string, config: VaultConfig): void {
tauriCall<void>('save_vault_config', { vaultPath, config })
.catch((err) => console.warn('Failed to save vault config:', err))
}
export function useVaultConfig(vaultPath: string) {
const config = useSyncExternalStore(subscribeVaultConfig, getVaultConfig)
useEffect(() => {
resetVaultConfigStore()
tauriCall<VaultConfig>('get_vault_config', { vaultPath })
.then((loaded) => {
const migrated = migrateLocalStorageToVaultConfig(loaded)
const needsSave = migrated !== loaded
bindVaultConfigStore(migrated, (c) => persistConfig(vaultPath, c))
applyToModules(migrated)
if (needsSave) persistConfig(vaultPath, migrated)
})
.catch((err) => {
console.warn('Failed to load vault config:', err)
const migrated = migrateLocalStorageToVaultConfig(null)
bindVaultConfigStore(migrated, (c) => persistConfig(vaultPath, c))
applyToModules(migrated)
if (migrated.zoom !== null || migrated.tag_colors !== null || migrated.status_colors !== null) {
persistConfig(vaultPath, migrated)
}
})
const loaded = loadFromStorage(vaultPath)
const migrated = migrateLocalStorageToVaultConfig(loaded)
const needsSave = migrated !== loaded
bindVaultConfigStore(migrated, (c) => saveToStorage(vaultPath, c))
applyToModules(migrated)
if (needsSave) saveToStorage(vaultPath, migrated)
return () => resetVaultConfigStore()
}, [vaultPath])

View File

@@ -362,15 +362,15 @@ describe('useVaultLoader', () => {
expect(result.current.entries).toHaveLength(1)
})
let response = ''
let response: { status: string; message: string } = { status: '', message: '' }
await act(async () => {
response = await result.current.commitAndPush('test commit')
})
expect(response).toBe('Committed and pushed')
expect(response.status).toBe('ok')
})
it('returns actionable message when push is rejected', async () => {
it('returns rejected status when push is rejected', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
@@ -383,16 +383,16 @@ describe('useVaultLoader', () => {
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
let response = ''
let response: { status: string; message: string } = { status: '', message: '' }
await act(async () => {
response = await result.current.commitAndPush('test commit')
})
expect(response).toContain('Pull first')
expect(response).not.toBe('Committed and pushed')
expect(response.status).toBe('rejected')
expect(response.message).toContain('Pull first')
})
it('returns network error message on network failure', async () => {
it('returns network error status on network failure', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
@@ -405,12 +405,13 @@ describe('useVaultLoader', () => {
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
let response = ''
let response: { status: string; message: string } = { status: '', message: '' }
await act(async () => {
response = await result.current.commitAndPush('test commit')
})
expect(response).toContain('network error')
expect(response.status).toBe('network_error')
expect(response.message).toContain('network error')
})
})

View File

@@ -15,15 +15,13 @@ async function loadVaultData(vaultPath: string) {
return { entries }
}
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
async function commitWithPush(vaultPath: string, message: string): Promise<GitPushResult> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { message })
const pushResult = await mockInvoke<GitPushResult>('git_push', {})
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
return mockInvoke<GitPushResult>('git_push', {})
}
await invoke<string>('git_commit', { vaultPath, message })
const pushResult = await invoke<GitPushResult>('git_push', { vaultPath })
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
return invoke<GitPushResult>('git_push', { vaultPath })
}
function useNewNoteTracker() {
@@ -156,7 +154,7 @@ export function useVaultLoader(vaultPath: string) {
const getNoteStatus = useCallback((path: string): NoteStatus =>
resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths])
const commitAndPush = useCallback((message: string): Promise<string> =>
const commitAndPush = useCallback((message: string): Promise<GitPushResult> =>
commitWithPush(vaultPath, message), [vaultPath])
const reloadVault = useCallback(

View File

@@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client'
import { TooltipProvider } from '@/components/ui/tooltip'
import './index.css'
import App from './App.tsx'
import NoteWindow from './NoteWindow.tsx'
import { isNoteWindow } from './utils/windowMode'
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
// at native level before React's synthetic events can call preventDefault).
@@ -12,10 +14,12 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) {
document.addEventListener('contextmenu', (e) => e.preventDefault(), true)
}
const RootComponent = isNoteWindow() ? NoteWindow : App
createRoot(document.getElementById('root')!).render(
<StrictMode>
<TooltipProvider>
<App />
<RootComponent />
</TooltipProvider>
</StrictMode>,
)

View File

@@ -732,452 +732,5 @@ rating: 5
# Designing Data-Intensive Applications
Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing.
`,
'/Users/luca/Laputa/theme/default.md': `---
type: Theme
Description: Light theme with warm, paper-like tones
background: "#FFFFFF"
foreground: "#37352F"
card: "#FFFFFF"
popover: "#FFFFFF"
primary: "#155DFF"
primary-foreground: "#FFFFFF"
secondary: "#EBEBEA"
secondary-foreground: "#37352F"
muted: "#F0F0EF"
muted-foreground: "#787774"
accent: "#EBEBEA"
accent-foreground: "#37352F"
destructive: "#E03E3E"
border: "#E9E9E7"
input: "#E9E9E7"
ring: "#155DFF"
sidebar: "#F7F6F3"
sidebar-foreground: "#37352F"
sidebar-border: "#E9E9E7"
sidebar-accent: "#EBEBEA"
text-primary: "#37352F"
text-secondary: "#787774"
text-tertiary: "#B4B4B4"
text-muted: "#B4B4B4"
text-heading: "#37352F"
bg-primary: "#FFFFFF"
bg-card: "#FFFFFF"
bg-sidebar: "#F7F6F3"
bg-hover: "#EBEBEA"
bg-hover-subtle: "#F0F0EF"
bg-selected: "#E8F4FE"
border-primary: "#E9E9E7"
accent-blue: "#155DFF"
accent-green: "#00B38B"
accent-orange: "#D9730D"
accent-red: "#E03E3E"
accent-purple: "#A932FF"
accent-yellow: "#F0B100"
accent-blue-light: "#155DFF14"
accent-green-light: "#00B38B14"
accent-purple-light: "#A932FF14"
accent-red-light: "#E03E3E14"
accent-yellow-light: "#F0B10014"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14px
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
editor-font-size: 15px
editor-line-height: 1.5
editor-max-width: 720px
editor-padding-horizontal: 40px
editor-padding-vertical: 20px
editor-paragraph-spacing: 8px
headings-h1-font-size: 32px
headings-h1-font-weight: 700
headings-h1-line-height: 1.2
headings-h1-margin-top: 32px
headings-h1-margin-bottom: 12px
headings-h1-color: "var(--text-heading)"
headings-h1-letter-spacing: -0.5px
headings-h2-font-size: 27px
headings-h2-font-weight: 600
headings-h2-line-height: 1.4
headings-h2-margin-top: 28px
headings-h2-margin-bottom: 10px
headings-h2-color: "var(--text-heading)"
headings-h2-letter-spacing: -0.5px
headings-h3-font-size: 20px
headings-h3-font-weight: 600
headings-h3-line-height: 1.4
headings-h3-margin-top: 24px
headings-h3-margin-bottom: 8px
headings-h3-color: "var(--text-heading)"
headings-h3-letter-spacing: -0.5px
headings-h4-font-size: 20px
headings-h4-font-weight: 600
headings-h4-line-height: 1.4
headings-h4-margin-top: 20px
headings-h4-margin-bottom: 6px
headings-h4-color: "var(--text-heading)"
headings-h4-letter-spacing: 0px
lists-bullet-size: 28px
lists-bullet-color: "#177bfd"
lists-indent-size: 24px
lists-item-spacing: 4px
lists-padding-left: 8px
lists-bullet-gap: 6px
checkboxes-size: 18px
checkboxes-border-radius: 3px
checkboxes-checked-color: "var(--accent-blue)"
checkboxes-unchecked-border-color: "var(--text-muted)"
checkboxes-gap: 8px
inline-styles-bold-font-weight: 700
inline-styles-bold-color: "var(--text-primary)"
inline-styles-italic-font-style: italic
inline-styles-italic-color: "var(--text-primary)"
inline-styles-strikethrough-color: "var(--text-tertiary)"
inline-styles-strikethrough-text-decoration: line-through
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
inline-styles-code-font-size: 14px
inline-styles-code-background-color: "var(--bg-hover-subtle)"
inline-styles-code-padding-horizontal: 4px
inline-styles-code-padding-vertical: 2px
inline-styles-code-border-radius: 3px
inline-styles-code-color: "var(--text-secondary)"
inline-styles-link-color: "var(--accent-blue)"
inline-styles-link-text-decoration: underline
inline-styles-wikilink-color: "var(--accent-blue)"
inline-styles-wikilink-text-decoration: none
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
inline-styles-wikilink-cursor: pointer
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
code-blocks-font-size: 13px
code-blocks-line-height: 1.5
code-blocks-background-color: "var(--bg-card)"
code-blocks-padding-horizontal: 16px
code-blocks-padding-vertical: 12px
code-blocks-border-radius: 6px
code-blocks-margin-vertical: 12px
blockquote-border-left-width: 3px
blockquote-border-left-color: "var(--accent-blue)"
blockquote-padding-left: 16px
blockquote-margin-vertical: 12px
blockquote-color: "var(--text-secondary)"
blockquote-font-style: italic
table-border-color: "var(--border-primary)"
table-header-background: "var(--bg-card)"
table-cell-padding-horizontal: 12px
table-cell-padding-vertical: 8px
table-font-size: 14px
horizontal-rule-color: "var(--border-primary)"
horizontal-rule-margin-vertical: 24px
horizontal-rule-thickness: 1px
colors-background: "var(--bg-primary)"
colors-text: "var(--text-primary)"
colors-text-secondary: "var(--text-secondary)"
colors-text-muted: "var(--text-muted)"
colors-heading: "var(--text-heading)"
colors-accent: "var(--accent-blue)"
colors-selection: "var(--bg-selected)"
colors-cursor: "var(--text-primary)"
---
# Default
Light theme with warm, paper-like tones.
`,
'/Users/luca/Laputa/theme/dark.md': `---
type: Theme
Description: Dark variant with deep navy tones
background: "#0f0f1a"
foreground: "#e0e0e0"
card: "#16162a"
popover: "#1e1e3a"
primary: "#155DFF"
primary-foreground: "#FFFFFF"
secondary: "#2a2a4a"
secondary-foreground: "#e0e0e0"
muted: "#1e1e3a"
muted-foreground: "#888888"
accent: "#2a2a4a"
accent-foreground: "#e0e0e0"
destructive: "#f44336"
border: "#2a2a4a"
input: "#2a2a4a"
ring: "#155DFF"
sidebar: "#1a1a2e"
sidebar-foreground: "#e0e0e0"
sidebar-border: "#2a2a4a"
sidebar-accent: "#2a2a4a"
text-primary: "#e0e0e0"
text-secondary: "#888888"
text-tertiary: "#666666"
text-muted: "#666666"
text-heading: "#e0e0e0"
bg-primary: "#0f0f1a"
bg-card: "#16162a"
bg-sidebar: "#1a1a2e"
bg-hover: "#2a2a4a"
bg-hover-subtle: "#1e1e3a"
bg-selected: "#155DFF22"
border-primary: "#2a2a4a"
accent-blue: "#155DFF"
accent-green: "#00B38B"
accent-orange: "#D9730D"
accent-red: "#f44336"
accent-purple: "#A932FF"
accent-yellow: "#F0B100"
accent-blue-light: "#155DFF33"
accent-green-light: "#00B38B33"
accent-purple-light: "#A932FF33"
accent-red-light: "#f4433633"
accent-yellow-light: "#F0B10033"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14px
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
editor-font-size: 15px
editor-line-height: 1.5
editor-max-width: 720px
editor-padding-horizontal: 40px
editor-padding-vertical: 20px
editor-paragraph-spacing: 8px
headings-h1-font-size: 32px
headings-h1-font-weight: 700
headings-h1-line-height: 1.2
headings-h1-margin-top: 32px
headings-h1-margin-bottom: 12px
headings-h1-color: "var(--text-heading)"
headings-h1-letter-spacing: -0.5px
headings-h2-font-size: 27px
headings-h2-font-weight: 600
headings-h2-line-height: 1.4
headings-h2-margin-top: 28px
headings-h2-margin-bottom: 10px
headings-h2-color: "var(--text-heading)"
headings-h2-letter-spacing: -0.5px
headings-h3-font-size: 20px
headings-h3-font-weight: 600
headings-h3-line-height: 1.4
headings-h3-margin-top: 24px
headings-h3-margin-bottom: 8px
headings-h3-color: "var(--text-heading)"
headings-h3-letter-spacing: -0.5px
headings-h4-font-size: 20px
headings-h4-font-weight: 600
headings-h4-line-height: 1.4
headings-h4-margin-top: 20px
headings-h4-margin-bottom: 6px
headings-h4-color: "var(--text-heading)"
headings-h4-letter-spacing: 0px
lists-bullet-size: 28px
lists-bullet-color: "#155DFF"
lists-indent-size: 24px
lists-item-spacing: 4px
lists-padding-left: 8px
lists-bullet-gap: 6px
checkboxes-size: 18px
checkboxes-border-radius: 3px
checkboxes-checked-color: "var(--accent-blue)"
checkboxes-unchecked-border-color: "var(--text-muted)"
checkboxes-gap: 8px
inline-styles-bold-font-weight: 700
inline-styles-bold-color: "var(--text-primary)"
inline-styles-italic-font-style: italic
inline-styles-italic-color: "var(--text-primary)"
inline-styles-strikethrough-color: "var(--text-tertiary)"
inline-styles-strikethrough-text-decoration: line-through
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
inline-styles-code-font-size: 14px
inline-styles-code-background-color: "var(--bg-hover-subtle)"
inline-styles-code-padding-horizontal: 4px
inline-styles-code-padding-vertical: 2px
inline-styles-code-border-radius: 3px
inline-styles-code-color: "var(--text-secondary)"
inline-styles-link-color: "var(--accent-blue)"
inline-styles-link-text-decoration: underline
inline-styles-wikilink-color: "var(--accent-blue)"
inline-styles-wikilink-text-decoration: none
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
inline-styles-wikilink-cursor: pointer
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
code-blocks-font-size: 13px
code-blocks-line-height: 1.5
code-blocks-background-color: "var(--bg-card)"
code-blocks-padding-horizontal: 16px
code-blocks-padding-vertical: 12px
code-blocks-border-radius: 6px
code-blocks-margin-vertical: 12px
blockquote-border-left-width: 3px
blockquote-border-left-color: "var(--accent-blue)"
blockquote-padding-left: 16px
blockquote-margin-vertical: 12px
blockquote-color: "var(--text-secondary)"
blockquote-font-style: italic
table-border-color: "var(--border-primary)"
table-header-background: "var(--bg-card)"
table-cell-padding-horizontal: 12px
table-cell-padding-vertical: 8px
table-font-size: 14px
horizontal-rule-color: "var(--border-primary)"
horizontal-rule-margin-vertical: 24px
horizontal-rule-thickness: 1px
colors-background: "var(--bg-primary)"
colors-text: "var(--text-primary)"
colors-text-secondary: "var(--text-secondary)"
colors-text-muted: "var(--text-muted)"
colors-heading: "var(--text-heading)"
colors-accent: "var(--accent-blue)"
colors-selection: "var(--bg-selected)"
colors-cursor: "var(--text-primary)"
---
# Dark
Dark variant with deep navy tones.
`,
'/Users/luca/Laputa/theme/minimal.md': `---
type: Theme
Description: High contrast, minimal chrome
background: "#FAFAFA"
foreground: "#111111"
card: "#FFFFFF"
popover: "#FFFFFF"
primary: "#000000"
primary-foreground: "#FFFFFF"
secondary: "#F0F0F0"
secondary-foreground: "#111111"
muted: "#F5F5F5"
muted-foreground: "#666666"
accent: "#F0F0F0"
accent-foreground: "#111111"
destructive: "#CC0000"
border: "#E0E0E0"
input: "#E0E0E0"
ring: "#000000"
sidebar: "#F5F5F5"
sidebar-foreground: "#111111"
sidebar-border: "#E0E0E0"
sidebar-accent: "#E8E8E8"
text-primary: "#111111"
text-secondary: "#666666"
text-tertiary: "#999999"
text-muted: "#999999"
text-heading: "#111111"
bg-primary: "#FAFAFA"
bg-card: "#FFFFFF"
bg-sidebar: "#F5F5F5"
bg-hover: "#EBEBEB"
bg-hover-subtle: "#F5F5F5"
bg-selected: "#00000014"
border-primary: "#E0E0E0"
accent-blue: "#000000"
accent-green: "#006600"
accent-orange: "#996600"
accent-red: "#CC0000"
accent-purple: "#660099"
accent-yellow: "#996600"
accent-blue-light: "#00000014"
accent-green-light: "#00660014"
accent-purple-light: "#66009914"
accent-red-light: "#CC000014"
accent-yellow-light: "#99660014"
font-family: "'SF Mono', 'Menlo', monospace"
font-size-base: 13px
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
editor-font-size: 15px
editor-line-height: 1.6
editor-max-width: 680px
editor-padding-horizontal: 40px
editor-padding-vertical: 20px
editor-paragraph-spacing: 8px
headings-h1-font-size: 32px
headings-h1-font-weight: 700
headings-h1-line-height: 1.2
headings-h1-margin-top: 32px
headings-h1-margin-bottom: 12px
headings-h1-color: "var(--text-heading)"
headings-h1-letter-spacing: -0.5px
headings-h2-font-size: 27px
headings-h2-font-weight: 600
headings-h2-line-height: 1.4
headings-h2-margin-top: 28px
headings-h2-margin-bottom: 10px
headings-h2-color: "var(--text-heading)"
headings-h2-letter-spacing: -0.5px
headings-h3-font-size: 20px
headings-h3-font-weight: 600
headings-h3-line-height: 1.4
headings-h3-margin-top: 24px
headings-h3-margin-bottom: 8px
headings-h3-color: "var(--text-heading)"
headings-h3-letter-spacing: -0.5px
headings-h4-font-size: 20px
headings-h4-font-weight: 600
headings-h4-line-height: 1.4
headings-h4-margin-top: 20px
headings-h4-margin-bottom: 6px
headings-h4-color: "var(--text-heading)"
headings-h4-letter-spacing: 0px
lists-bullet-size: 28px
lists-bullet-color: "#000000"
lists-indent-size: 24px
lists-item-spacing: 4px
lists-padding-left: 8px
lists-bullet-gap: 6px
checkboxes-size: 18px
checkboxes-border-radius: 3px
checkboxes-checked-color: "var(--accent-blue)"
checkboxes-unchecked-border-color: "var(--text-muted)"
checkboxes-gap: 8px
inline-styles-bold-font-weight: 700
inline-styles-bold-color: "var(--text-primary)"
inline-styles-italic-font-style: italic
inline-styles-italic-color: "var(--text-primary)"
inline-styles-strikethrough-color: "var(--text-tertiary)"
inline-styles-strikethrough-text-decoration: line-through
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
inline-styles-code-font-size: 14px
inline-styles-code-background-color: "var(--bg-hover-subtle)"
inline-styles-code-padding-horizontal: 4px
inline-styles-code-padding-vertical: 2px
inline-styles-code-border-radius: 3px
inline-styles-code-color: "var(--text-secondary)"
inline-styles-link-color: "var(--accent-blue)"
inline-styles-link-text-decoration: underline
inline-styles-wikilink-color: "var(--accent-blue)"
inline-styles-wikilink-text-decoration: none
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
inline-styles-wikilink-cursor: pointer
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
code-blocks-font-size: 13px
code-blocks-line-height: 1.5
code-blocks-background-color: "var(--bg-card)"
code-blocks-padding-horizontal: 16px
code-blocks-padding-vertical: 12px
code-blocks-border-radius: 6px
code-blocks-margin-vertical: 12px
blockquote-border-left-width: 3px
blockquote-border-left-color: "var(--accent-blue)"
blockquote-padding-left: 16px
blockquote-margin-vertical: 12px
blockquote-color: "var(--text-secondary)"
blockquote-font-style: italic
table-border-color: "var(--border-primary)"
table-header-background: "var(--bg-card)"
table-cell-padding-horizontal: 12px
table-cell-padding-vertical: 8px
table-font-size: 14px
horizontal-rule-color: "var(--border-primary)"
horizontal-rule-margin-vertical: 24px
horizontal-rule-thickness: 1px
colors-background: "var(--bg-primary)"
colors-text: "var(--text-primary)"
colors-text-secondary: "var(--text-secondary)"
colors-text-muted: "var(--text-muted)"
colors-heading: "var(--text-heading)"
colors-accent: "var(--accent-blue)"
colors-selection: "var(--bg-selected)"
colors-cursor: "var(--text-primary)"
---
# Minimal
High contrast, minimal chrome.
`,
}

View File

@@ -1242,87 +1242,5 @@ function generateBulkEntries(count: number): VaultEntry[] {
return entries
}
// Theme entries — seeded vault themes
MOCK_ENTRIES.push(
{
path: '/Users/luca/Laputa/theme/default.md',
filename: 'default.md',
title: 'Default',
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 30,
fileSize: 512,
snippet: 'Light theme with warm, paper-like tones.',
wordCount: 10,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/theme/dark.md',
filename: 'dark.md',
title: 'Dark',
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 30,
fileSize: 512,
snippet: 'Dark variant with deep navy tones.',
wordCount: 10,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
{
path: '/Users/luca/Laputa/theme/minimal.md',
filename: 'minimal.md',
title: 'Minimal',
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 30,
fileSize: 512,
snippet: 'High contrast, minimal chrome.',
wordCount: 10,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
},
)
// Append 9000 generated entries for realistic large-vault testing
MOCK_ENTRIES.push(...generateBulkEntries(9000))

View File

@@ -3,7 +3,7 @@
* Each handler simulates a Tauri backend command.
*/
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
import type { VaultEntry, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, PulseCommit } from '../types'
import { MOCK_CONTENT } from './mock-content'
import { MOCK_ENTRIES } from './mock-entries'
@@ -85,34 +85,11 @@ let mockSettings: Settings = {
let mockLastVaultPath: string | null = null
let mockVaultSettings: VaultSettings = { theme: null }
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
vaults: [],
active_vault: null,
}
const mockThemes: ThemeFile[] = [
{
id: 'default', name: 'Default', description: 'Light theme with warm, paper-like tones',
colors: { background: '#FFFFFF', foreground: '#37352F', primary: '#155DFF', 'sidebar-background': '#F7F6F3', border: '#E9E9E7', muted: '#F0F0EF' },
typography: { 'font-family': "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", 'font-size-base': '14px' },
spacing: { 'sidebar-width': '250px' },
},
{
id: 'dark', name: 'Dark', description: 'Dark variant with deep navy tones',
colors: { background: '#0f0f1a', foreground: '#e0e0e0', primary: '#155DFF', 'sidebar-background': '#1a1a2e', border: '#2a2a4a', muted: '#1e1e3a' },
typography: { 'font-family': "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", 'font-size-base': '14px' },
spacing: { 'sidebar-width': '250px' },
},
{
id: 'minimal', name: 'Minimal', description: 'High contrast, minimal chrome',
colors: { background: '#FAFAFA', foreground: '#111111', primary: '#000000', 'sidebar-background': '#F5F5F5', border: '#E0E0E0', muted: '#F5F5F5' },
typography: { 'font-family': "'SF Mono', 'Menlo', monospace", 'font-size-base': '13px' },
spacing: { 'sidebar-width': '220px' },
},
]
let mockDeviceFlowPollCount = 0
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) {
@@ -180,6 +157,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }),
git_remote_status: (): GitRemoteStatus => ({ branch: 'main', ahead: 0, behind: 0, hasRemote: true }),
get_vault_pulse: (args: { limit?: number }): PulseCommit[] => {
const limit = args.limit ?? 30
const ts = Math.floor(Date.now() / 1000)
@@ -287,195 +265,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0, last_indexed_commit: 'abc123', last_indexed_at: Math.floor(Date.now() / 1000) - 3600 }),
start_indexing: () => null,
trigger_incremental_index: () => null,
list_themes: (): ThemeFile[] => [...mockThemes],
get_theme: (args: { themeId: string }): ThemeFile => {
const t = mockThemes.find(t => t.id === args.themeId)
if (!t) throw new Error(`Theme not found: ${args.themeId}`)
return { ...t }
},
get_vault_settings: (): VaultSettings => ({ ...mockVaultSettings }),
save_vault_settings: (args: { settings: VaultSettings }) => { mockVaultSettings = { ...args.settings }; return null },
set_active_theme: (args: { themeId: string }) => { mockVaultSettings.theme = args.themeId; return null },
create_theme: (args: { sourceId?: string }): string => {
const sourceId = args.sourceId ?? 'default'
const source = mockThemes.find(t => t.id === sourceId) ?? mockThemes[0]
const newId = `untitled-${mockThemes.length}`
mockThemes.push({ ...source, id: newId, name: 'Untitled Theme' })
return newId
},
create_vault_theme: (args: { vaultPath: string; name?: string | null }): string => {
const displayName = args.name ?? 'Untitled Theme'
const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme'
const path = `${args.vaultPath}/theme/${slug}.md`
MOCK_CONTENT[path] = `---
Is A: Theme
Description: ${displayName} theme
background: "#FFFFFF"
foreground: "#37352F"
card: "#FFFFFF"
popover: "#FFFFFF"
primary: "#155DFF"
primary-foreground: "#FFFFFF"
secondary: "#EBEBEA"
secondary-foreground: "#37352F"
muted: "#F0F0EF"
muted-foreground: "#787774"
accent: "#EBEBEA"
accent-foreground: "#37352F"
destructive: "#E03E3E"
border: "#E9E9E7"
input: "#E9E9E7"
ring: "#155DFF"
sidebar: "#F7F6F3"
sidebar-foreground: "#37352F"
sidebar-border: "#E9E9E7"
sidebar-accent: "#EBEBEA"
text-primary: "#37352F"
text-secondary: "#787774"
text-tertiary: "#B4B4B4"
text-muted: "#B4B4B4"
text-heading: "#37352F"
bg-primary: "#FFFFFF"
bg-card: "#FFFFFF"
bg-sidebar: "#F7F6F3"
bg-hover: "#EBEBEA"
bg-hover-subtle: "#F0F0EF"
bg-selected: "#E8F4FE"
border-primary: "#E9E9E7"
accent-blue: "#155DFF"
accent-green: "#00B38B"
accent-orange: "#D9730D"
accent-red: "#E03E3E"
accent-purple: "#A932FF"
accent-yellow: "#F0B100"
accent-blue-light: "#155DFF14"
accent-green-light: "#00B38B14"
accent-purple-light: "#A932FF14"
accent-red-light: "#E03E3E14"
accent-yellow-light: "#F0B10014"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14px
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
editor-font-size: 15px
editor-line-height: 1.5
editor-max-width: 720px
editor-padding-horizontal: 40px
editor-padding-vertical: 20px
editor-paragraph-spacing: 8px
headings-h1-font-size: 32px
headings-h1-font-weight: 700
headings-h1-line-height: 1.2
headings-h1-margin-top: 32px
headings-h1-margin-bottom: 12px
headings-h1-color: "var(--text-heading)"
headings-h1-letter-spacing: -0.5px
headings-h2-font-size: 27px
headings-h2-font-weight: 600
headings-h2-line-height: 1.4
headings-h2-margin-top: 28px
headings-h2-margin-bottom: 10px
headings-h2-color: "var(--text-heading)"
headings-h2-letter-spacing: -0.5px
headings-h3-font-size: 20px
headings-h3-font-weight: 600
headings-h3-line-height: 1.4
headings-h3-margin-top: 24px
headings-h3-margin-bottom: 8px
headings-h3-color: "var(--text-heading)"
headings-h3-letter-spacing: -0.5px
headings-h4-font-size: 20px
headings-h4-font-weight: 600
headings-h4-line-height: 1.4
headings-h4-margin-top: 20px
headings-h4-margin-bottom: 6px
headings-h4-color: "var(--text-heading)"
headings-h4-letter-spacing: 0px
lists-bullet-size: 28px
lists-bullet-color: "#177bfd"
lists-indent-size: 24px
lists-item-spacing: 4px
lists-padding-left: 8px
lists-bullet-gap: 6px
checkboxes-size: 18px
checkboxes-border-radius: 3px
checkboxes-checked-color: "var(--accent-blue)"
checkboxes-unchecked-border-color: "var(--text-muted)"
checkboxes-gap: 8px
inline-styles-bold-font-weight: 700
inline-styles-bold-color: "var(--text-primary)"
inline-styles-italic-font-style: italic
inline-styles-italic-color: "var(--text-primary)"
inline-styles-strikethrough-color: "var(--text-tertiary)"
inline-styles-strikethrough-text-decoration: line-through
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
inline-styles-code-font-size: 14px
inline-styles-code-background-color: "var(--bg-hover-subtle)"
inline-styles-code-padding-horizontal: 4px
inline-styles-code-padding-vertical: 2px
inline-styles-code-border-radius: 3px
inline-styles-code-color: "var(--text-secondary)"
inline-styles-link-color: "var(--accent-blue)"
inline-styles-link-text-decoration: underline
inline-styles-wikilink-color: "var(--accent-blue)"
inline-styles-wikilink-text-decoration: none
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
inline-styles-wikilink-cursor: pointer
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
code-blocks-font-size: 13px
code-blocks-line-height: 1.5
code-blocks-background-color: "var(--bg-card)"
code-blocks-padding-horizontal: 16px
code-blocks-padding-vertical: 12px
code-blocks-border-radius: 6px
code-blocks-margin-vertical: 12px
blockquote-border-left-width: 3px
blockquote-border-left-color: "var(--accent-blue)"
blockquote-padding-left: 16px
blockquote-margin-vertical: 12px
blockquote-color: "var(--text-secondary)"
blockquote-font-style: italic
table-border-color: "var(--border-primary)"
table-header-background: "var(--bg-card)"
table-cell-padding-horizontal: 12px
table-cell-padding-vertical: 8px
table-font-size: 14px
horizontal-rule-color: "var(--border-primary)"
horizontal-rule-margin-vertical: 24px
horizontal-rule-thickness: 1px
colors-background: "var(--bg-primary)"
colors-text: "var(--text-primary)"
colors-text-secondary: "var(--text-secondary)"
colors-text-muted: "var(--text-muted)"
colors-heading: "var(--text-heading)"
colors-accent: "var(--accent-blue)"
colors-selection: "var(--bg-selected)"
colors-cursor: "var(--text-primary)"
---
# ${displayName}
A custom ${displayName} theme for Laputa.
`
const now = Date.now() / 1000
MOCK_ENTRIES.push({
path, filename: `${slug}.md`, title: displayName, isA: 'Theme',
aliases: [], belongsTo: [], relatedTo: [], status: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 512, snippet: `A custom ${displayName} theme.`,
wordCount: 10, relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null, visible: null,
outgoingLinks: [], properties: {},
})
syncWindowContent()
return path
},
ensure_vault_themes: (): null => null,
restore_default_themes: (): string => 'Default themes restored',
repair_vault: (): string => 'Vault repaired',
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, editor_mode: null, tag_colors: null, status_colors: null, property_display_modes: null }),
save_vault_config: (): null => null,
}
export function addMockEntry(_entry: VaultEntry, content: string): void {

View File

@@ -83,7 +83,14 @@ export interface GitPushResult {
message: string
}
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict'
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict' | 'pull_required'
export interface GitRemoteStatus {
branch: string
ahead: number
behind: number
hasRemote: boolean
}
export interface DeviceFlowStart {
device_code: string
@@ -132,23 +139,7 @@ export interface SearchResponse {
export type SearchMode = 'keyword' | 'semantic' | 'hybrid'
export interface ThemeFile {
/** For vault-based themes: absolute note path. For legacy JSON themes: filename stem. */
id: string
name: string
description: string
/** Absolute path to the vault note (vault-based themes only). */
path?: string
colors: Record<string, string>
typography: Record<string, string>
spacing: Record<string, string>
}
export interface VaultSettings {
theme: string | null
}
/** Vault-wide UI configuration stored in config/ui.config.md. */
/** Vault-wide UI configuration stored in ui.config.md at vault root. */
export interface VaultConfig {
zoom: number | null
view_mode: string | null
@@ -176,7 +167,9 @@ export interface PulseCommit {
deleted: number
}
export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse' | 'inbox'
export type InboxPeriod = 'week' | 'month' | 'quarter' | 'all'
export type SidebarSelection =
| { kind: 'filter'; filter: SidebarFilter }

View File

@@ -1,10 +0,0 @@
export function formatIndexedElapsed(lastIndexedTime: number | null): string {
if (!lastIndexedTime) return ''
const secs = Math.round((Date.now() - lastIndexedTime) / 1000)
if (secs < 60) return 'Indexed just now'
const mins = Math.floor(secs / 60)
if (mins < 60) return `Indexed ${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `Indexed ${hrs}h ago`
return `Indexed ${Math.floor(hrs / 24)}d ago`
}

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 } from './noteListHelpers'
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, buildValidLinkTargets, isInboxEntry, filterInboxEntries } from './noteListHelpers'
import type { VaultEntry } from '../types'
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
@@ -324,6 +324,84 @@ describe('buildRelationshipGroups', () => {
const groups = buildRelationshipGroups(entity, [entity, linker])
expect(groups.find((g) => g.label === 'Backlinks')!.entries[0].title).toBe('Linker')
})
it('resolves all entries in a large Notes relationship (regression: No Code)', () => {
// Simulates the No Code topic note with 32 Notes, 2 Referred by Data, 1 Belongs to
const noteRefs = [
'8020', 'airdev-build-hub', 'airdev-leader', 'budibase', 'bullet-launch',
'canvas', 'chameleon', 'felt', 'flutterflow', 'framer-ai',
'jumpstart', 'mailparser', 'make', 'michele-sampieri', 'n8n-a',
'n8n-ai', 'nocodey', 'outseta', 'lemon-squeezy', 'retool',
'rise-no-code', 'scene', 'scrapingbee', 'softr', 'superblocks',
'superwall', 'tails', 'supabase', 'varun-anand', 'xano',
'directus', 'framer-design',
]
const noteEntries = noteRefs.map((slug, i) => makeEntry({
path: `/Laputa/${slug}.md`, filename: `${slug}.md`, title: `Title ${slug}`,
modifiedAt: 1700000000 - i * 100,
}))
const engineering = makeEntry({
path: '/Laputa/engineering.md', filename: 'engineering.md', title: 'Engineering',
modifiedAt: 1700000000,
})
const entity = makeEntry({
path: '/Laputa/no-code.md', filename: 'no-code.md', title: 'No Code',
isA: 'Topic',
relationships: {
'Belongs to': ['[[engineering|Engineering]]'],
Notes: noteRefs.map((slug) => `[[${slug}|Title ${slug}]]`),
'Referred by Data': ['[[michele-sampieri|Michele Sampieri]]', '[[varun-anand|Varun Anand]]'],
},
})
const allEntries = [entity, engineering, ...noteEntries]
const groups = buildRelationshipGroups(entity, allEntries)
const belongsGroup = groups.find((g) => g.label === 'Belongs to')
expect(belongsGroup).toBeDefined()
expect(belongsGroup!.entries).toHaveLength(1)
const notesGroup = groups.find((g) => g.label === 'Notes')
expect(notesGroup).toBeDefined()
expect(notesGroup!.entries).toHaveLength(32)
// michele-sampieri and varun-anand already consumed by Notes → Referred by Data has 0 new
const referredGroup = groups.find((g) => g.label === 'Referred by Data')
expect(referredGroup).toBeUndefined()
})
it('resolves refs by title when filename differs from wikilink target', () => {
// Wikilink [[Airdev]] but filename is airdev-tool.md, title is "Airdev"
const airdev = makeEntry({
path: '/vault/airdev-tool.md', filename: 'airdev-tool.md', title: 'Airdev',
})
const budibase = makeEntry({
path: '/vault/budibase-app.md', filename: 'budibase-app.md', title: 'Budibase',
aliases: ['Budi'],
})
const entity = makeEntry({
path: '/vault/no-code.md', filename: 'no-code.md', title: 'No Code',
relationships: { Notes: ['[[Airdev]]', '[[Budi]]'] },
})
const groups = buildRelationshipGroups(entity, [entity, airdev, budibase])
const notesGroup = groups.find((g) => g.label === 'Notes')
expect(notesGroup).toBeDefined()
expect(notesGroup!.entries).toHaveLength(2)
expect(notesGroup!.entries.map(e => e.title).sort()).toEqual(['Airdev', 'Budibase'])
})
it('resolves Children via title match when belongsTo target differs from filename', () => {
// Child's belongsTo uses [[No Code]] but entity filename is no-code-topic.md
const child = makeEntry({
path: '/vault/tool.md', filename: 'tool.md', title: 'Tool',
belongsTo: ['[[No Code]]'], modifiedAt: 1700000000,
})
const entity = makeEntry({
path: '/vault/no-code-topic.md', filename: 'no-code-topic.md', title: 'No Code',
relationships: {},
})
const groups = buildRelationshipGroups(entity, [entity, child])
expect(groups.find((g) => g.label === 'Children')!.entries).toHaveLength(1)
})
})
describe('getSortComparator — custom properties', () => {
@@ -485,3 +563,135 @@ describe('parseSortConfig', () => {
}
})
})
// --- Inbox ---
describe('buildValidLinkTargets', () => {
it('builds a set of titles, filename stems, and path stems', () => {
const entries = [
makeEntry({ path: '/vault/project/my-project.md', filename: 'my-project.md', title: 'My Project', aliases: ['MP'] }),
makeEntry({ path: '/vault/note/test.md', filename: 'test.md', title: 'Test Note' }),
]
const targets = buildValidLinkTargets(entries)
expect(targets.has('My Project')).toBe(true)
expect(targets.has('Test Note')).toBe(true)
expect(targets.has('my-project')).toBe(true)
expect(targets.has('test')).toBe(true)
expect(targets.has('MP')).toBe(true)
// path stems (last 2 segments without .md)
expect(targets.has('project/my-project')).toBe(true)
expect(targets.has('note/test')).toBe(true)
})
})
describe('isInboxEntry', () => {
const allEntries = [
makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI', aliases: [] }),
makeEntry({ path: '/vault/project/laputa.md', filename: 'laputa.md', title: 'Laputa' }),
]
const validTargets = buildValidLinkTargets(allEntries)
it('returns true for a note with no outgoing links and no relationships', () => {
const note = makeEntry({ outgoingLinks: [], relationships: {}, belongsTo: [], relatedTo: [] })
expect(isInboxEntry(note, validTargets)).toBe(true)
})
it('returns false for a trashed note', () => {
const note = makeEntry({ trashed: true, outgoingLinks: [], relationships: {} })
expect(isInboxEntry(note, validTargets)).toBe(false)
})
it('returns false for an archived note', () => {
const note = makeEntry({ archived: true, outgoingLinks: [], relationships: {} })
expect(isInboxEntry(note, validTargets)).toBe(false)
})
it('returns false for a note with valid outgoing links', () => {
const note = makeEntry({ outgoingLinks: ['AI'] })
expect(isInboxEntry(note, validTargets)).toBe(false)
})
it('returns true for a note with only broken outgoing links (non-existent targets)', () => {
const note = makeEntry({ outgoingLinks: ['NonExistent Page', 'Another Missing'] })
expect(isInboxEntry(note, validTargets)).toBe(true)
})
it('returns false for a note with valid frontmatter relationships', () => {
const note = makeEntry({ outgoingLinks: [], relationships: { 'Related to': ['[[AI]]'] } })
expect(isInboxEntry(note, validTargets)).toBe(false)
})
it('returns false for a note with belongsTo pointing to real note', () => {
const note = makeEntry({ outgoingLinks: [], belongsTo: ['[[Laputa]]'] })
expect(isInboxEntry(note, validTargets)).toBe(false)
})
it('returns false for a note with relatedTo pointing to real note', () => {
const note = makeEntry({ outgoingLinks: [], relatedTo: ['[[AI]]'] })
expect(isInboxEntry(note, validTargets)).toBe(false)
})
it('returns true for a note with only broken relationship refs', () => {
const note = makeEntry({ outgoingLinks: [], relationships: { 'Relates': ['[[Ghost]]'] }, belongsTo: ['[[Missing]]'] })
expect(isInboxEntry(note, validTargets)).toBe(true)
})
it('excludes Type entries from inbox', () => {
const note = makeEntry({ isA: 'Type', outgoingLinks: [], relationships: {} })
expect(isInboxEntry(note, validTargets)).toBe(false)
})
})
describe('filterInboxEntries', () => {
const now = Math.floor(Date.now() / 1000)
const DAY = 86400
const allEntries = [
makeEntry({ path: '/vault/a.md', filename: 'a.md', title: 'A', createdAt: now - 2 * DAY, outgoingLinks: [] }),
makeEntry({ path: '/vault/b.md', filename: 'b.md', title: 'B', createdAt: now - 15 * DAY, outgoingLinks: [] }),
makeEntry({ path: '/vault/c.md', filename: 'c.md', title: 'C', createdAt: now - 60 * DAY, outgoingLinks: [] }),
makeEntry({ path: '/vault/d.md', filename: 'd.md', title: 'D', createdAt: now - 120 * DAY, outgoingLinks: [] }),
makeEntry({ path: '/vault/linked.md', filename: 'linked.md', title: 'Linked', createdAt: now - 1 * DAY, outgoingLinks: ['A'] }),
]
it('filters by "week" period (last 7 days)', () => {
const result = filterInboxEntries(allEntries, 'week')
expect(result.map(e => e.title)).toEqual(['A'])
})
it('filters by "month" period (last 30 days)', () => {
const result = filterInboxEntries(allEntries, 'month')
expect(result.map(e => e.title)).toEqual(['A', 'B'])
})
it('filters by "quarter" period (last 90 days)', () => {
const result = filterInboxEntries(allEntries, 'quarter')
expect(result.map(e => e.title)).toEqual(['A', 'B', 'C'])
})
it('filters by "all" period', () => {
const result = filterInboxEntries(allEntries, 'all')
expect(result.map(e => e.title)).toEqual(['A', 'B', 'C', 'D'])
})
it('sorts by createdAt descending', () => {
const result = filterInboxEntries(allEntries, 'all')
for (let i = 1; i < result.length; i++) {
expect((result[i - 1].createdAt ?? 0)).toBeGreaterThanOrEqual((result[i].createdAt ?? 0))
}
})
it('excludes linked notes', () => {
const result = filterInboxEntries(allEntries, 'all')
expect(result.find(e => e.title === 'Linked')).toBeUndefined()
})
it('returns empty array when all notes have valid outgoing links', () => {
const linked = [
makeEntry({ path: '/vault/x.md', title: 'X', outgoingLinks: ['Y'] }),
makeEntry({ path: '/vault/y.md', title: 'Y', outgoingLinks: ['X'] }),
]
const result = filterInboxEntries(linked, 'all')
expect(result).toEqual([])
})
})

View File

@@ -1,4 +1,5 @@
import type { VaultEntry, SidebarSelection } from '../types'
import type { VaultEntry, SidebarSelection, InboxPeriod } from '../types'
import { wikilinkTarget, resolveEntry } from './wikilink'
export type NoteListFilter = 'open' | 'archived' | 'trashed'
@@ -61,25 +62,12 @@ export function formatSearchSubtitle(entry: VaultEntry): string {
}
function refsMatch(refs: string[], entry: VaultEntry): boolean {
const stem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
const fileStem = entry.filename.replace(/\.md$/, '')
return refs.some((ref) => {
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
return inner === stem || inner.split('/').pop() === fileStem
})
return refs.some((ref) => resolveEntry([entry], wikilinkTarget(ref)) !== undefined)
}
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
return refs
.map((ref) => {
const inner = ref.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
return entries.find((e) => {
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (stem === inner) return true
const fileStem = e.filename.replace(/\.md$/, '')
return fileStem === inner.split('/').pop()
})
})
.map((ref) => resolveEntry(entries, wikilinkTarget(ref)))
.filter((e): e is VaultEntry => e !== undefined)
}
@@ -327,6 +315,7 @@ function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFil
const typeEntries = entries.filter((e) => e.isA === selection.type)
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
}
if (selection.filter === 'all' && subFilter) return applySubFilter(entries, subFilter)
return filterByFilterType(entries, selection.filter)
}
@@ -353,3 +342,99 @@ export function countByFilter(entries: VaultEntry[], type: string): Record<NoteL
}
return { open, archived, trashed }
}
/** Count notes per sub-filter across all entries (no type filter). */
export function countAllByFilter(entries: VaultEntry[]): Record<NoteListFilter, number> {
let open = 0, archived = 0, trashed = 0
for (const e of entries) {
if (e.trashed) trashed++
else if (e.archived) archived++
else open++
}
return { open, archived, trashed }
}
// --- Inbox ---
/** Build a set of all valid link targets (titles, aliases, filename stems, path stems). */
export function buildValidLinkTargets(entries: VaultEntry[]): Set<string> {
const targets = new Set<string>()
for (const e of entries) {
targets.add(e.title)
const fileStem = e.filename.replace(/\.md$/, '')
targets.add(fileStem)
// path stem: everything after vault root, minus .md
// E.g. /Users/luca/Laputa/project/foo.md → project/foo
const parts = e.path.replace(/\.md$/, '').split('/')
// Try from index that gives "folder/name" pattern — skip first segments
if (parts.length >= 2) {
const last2 = parts.slice(-2).join('/')
if (last2 !== fileStem) targets.add(last2)
}
for (const alias of e.aliases) targets.add(alias)
}
return targets
}
function extractRef(raw: string): string {
return raw.replace(/^\[\[/, '').replace(/\]\]$/, '').split('|')[0]
}
function hasValidRef(refs: string[], validTargets: Set<string>): boolean {
return refs.some((raw) => {
const inner = extractRef(raw)
return validTargets.has(inner) || validTargets.has(inner.split('/').pop() ?? '')
})
}
/** Check if entry has any valid outgoing link (body or frontmatter) that resolves to a real note. */
export function isInboxEntry(entry: VaultEntry, validTargets: Set<string>): boolean {
if (entry.trashed || entry.archived) return false
if (entry.isA === 'Type') return false
// Check body outgoing links
if (entry.outgoingLinks.some((link) => validTargets.has(link) || validTargets.has(link.split('/').pop() ?? ''))) return false
// Check frontmatter relationship refs
if (entry.belongsTo?.length && hasValidRef(entry.belongsTo, validTargets)) return false
if (entry.relatedTo?.length && hasValidRef(entry.relatedTo, validTargets)) return false
if (entry.relationships) {
for (const refs of Object.values(entry.relationships)) {
if (hasValidRef(refs, validTargets)) return false
}
}
return true
}
const INBOX_PERIOD_DAYS: Record<InboxPeriod, number> = {
week: 7, month: 30, quarter: 90, all: Infinity,
}
/** Filter entries for the Inbox view: no valid relationships, within the given time period, sorted by createdAt desc. */
export function filterInboxEntries(entries: VaultEntry[], period: InboxPeriod): VaultEntry[] {
const validTargets = buildValidLinkTargets(entries)
const now = Math.floor(Date.now() / 1000)
const cutoff = period === 'all' ? 0 : now - INBOX_PERIOD_DAYS[period] * 86400
return entries
.filter((e) => isInboxEntry(e, validTargets) && (e.createdAt ?? 0) >= cutoff)
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))
}
/** Count inbox entries per period. */
export function countInboxByPeriod(entries: VaultEntry[]): Record<InboxPeriod, number> {
const validTargets = buildValidLinkTargets(entries)
const inbox = entries.filter((e) => isInboxEntry(e, validTargets))
const now = Math.floor(Date.now() / 1000)
let week = 0, month = 0, quarter = 0
for (const e of inbox) {
const age = now - (e.createdAt ?? 0)
if (age <= 7 * 86400) week++
if (age <= 30 * 86400) month++
if (age <= 90 * 86400) quarter++
}
return { week, month, quarter, all: inbox.length }
}

View File

@@ -0,0 +1,23 @@
import { isTauri } from '../mock-tauri'
/**
* Opens a note in a new Tauri window with a minimal editor-only layout.
* In browser mode (non-Tauri), this is a no-op.
*/
export async function openNoteInNewWindow(notePath: string, vaultPath: string, noteTitle: string): Promise<void> {
if (!isTauri()) return
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const label = `note-${Date.now()}`
const url = `index.html?window=note&path=${encodeURIComponent(notePath)}&vault=${encodeURIComponent(vaultPath)}&title=${encodeURIComponent(noteTitle)}`
new WebviewWindow(label, {
url,
title: noteTitle,
width: 800,
height: 700,
resizable: true,
titleBarStyle: 'overlay',
hiddenTitle: true,
})
}

View File

@@ -1,216 +0,0 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { buildThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from './themeSchema'
import type { ThemeProperty } from './themeSchema'
describe('buildThemeSchema', () => {
const schema = buildThemeSchema()
it('returns all top-level sections from theme.json', () => {
const ids = schema.map(s => s.id)
expect(ids).toContain('editor')
expect(ids).toContain('headings')
expect(ids).toContain('lists')
expect(ids).toContain('checkboxes')
expect(ids).toContain('inlineStyles')
expect(ids).toContain('codeBlocks')
expect(ids).toContain('blockquote')
expect(ids).toContain('table')
expect(ids).toContain('horizontalRule')
expect(ids).toContain('colors')
})
it('assigns human-readable labels to sections', () => {
const editor = schema.find(s => s.id === 'editor')!
expect(editor.label).toBe('Typography')
const headings = schema.find(s => s.id === 'headings')!
expect(headings.label).toBe('Headings')
})
it('produces flat CSS variable names from editor section', () => {
const editor = schema.find(s => s.id === 'editor')!
const vars = editor.properties.map(p => p.cssVar)
expect(vars).toContain('editor-font-family')
expect(vars).toContain('editor-font-size')
expect(vars).toContain('editor-line-height')
expect(vars).toContain('editor-max-width')
expect(vars).toContain('editor-padding-horizontal')
expect(vars).toContain('editor-paragraph-spacing')
})
it('detects numeric input type for number values', () => {
const editor = schema.find(s => s.id === 'editor')!
const fontSize = editor.properties.find(p => p.cssVar === 'editor-font-size')!
expect(fontSize.inputType).toBe('number')
expect(fontSize.unit).toBe('px')
expect(fontSize.defaultValue).toBe(15)
})
it('detects unitless numbers for lineHeight and fontWeight', () => {
const editor = schema.find(s => s.id === 'editor')!
const lineHeight = editor.properties.find(p => p.cssVar === 'editor-line-height')!
expect(lineHeight.inputType).toBe('number')
expect(lineHeight.unit).toBeUndefined()
})
it('detects text input type for font family', () => {
const editor = schema.find(s => s.id === 'editor')!
const fontFamily = editor.properties.find(p => p.cssVar === 'editor-font-family')!
expect(fontFamily.inputType).toBe('text')
})
it('creates subsections for headings h1-h4', () => {
const headings = schema.find(s => s.id === 'headings')!
const subIds = headings.subsections.map(s => s.id)
expect(subIds).toContain('h1')
expect(subIds).toContain('h2')
expect(subIds).toContain('h3')
expect(subIds).toContain('h4')
})
it('produces correct CSS var names for heading subsections', () => {
const headings = schema.find(s => s.id === 'headings')!
const h1 = headings.subsections.find(s => s.id === 'h1')!
const vars = h1.properties.map(p => p.cssVar)
expect(vars).toContain('headings-h1-font-size')
expect(vars).toContain('headings-h1-font-weight')
expect(vars).toContain('headings-h1-line-height')
expect(vars).toContain('headings-h1-margin-top')
expect(vars).toContain('headings-h1-color')
expect(vars).toContain('headings-h1-letter-spacing')
})
it('detects color values from var(--) references', () => {
const headings = schema.find(s => s.id === 'headings')!
const h1 = headings.subsections.find(s => s.id === 'h1')!
const color = h1.properties.find(p => p.cssVar === 'headings-h1-color')!
expect(color.inputType).toBe('color')
})
it('detects hex color values', () => {
const lists = schema.find(s => s.id === 'lists')!
const bulletColor = lists.properties.find(p => p.cssVar === 'lists-bullet-color')!
expect(bulletColor.inputType).toBe('color')
})
it('creates subsections for inline styles', () => {
const inline = schema.find(s => s.id === 'inlineStyles')!
const subIds = inline.subsections.map(s => s.id)
expect(subIds).toContain('bold')
expect(subIds).toContain('italic')
expect(subIds).toContain('code')
expect(subIds).toContain('link')
expect(subIds).toContain('wikilink')
})
it('produces correct CSS var names for code blocks section', () => {
const codeBlocks = schema.find(s => s.id === 'codeBlocks')!
const vars = codeBlocks.properties.map(p => p.cssVar)
expect(vars).toContain('code-blocks-font-family')
expect(vars).toContain('code-blocks-font-size')
expect(vars).toContain('code-blocks-background-color')
expect(vars).toContain('code-blocks-border-radius')
})
it('skips array values like nestedBulletSymbols', () => {
const lists = schema.find(s => s.id === 'lists')!
const vars = lists.properties.map(p => p.cssVar)
expect(vars).not.toContain('lists-nested-bullet-symbols')
})
it('assigns select input type for fontStyle', () => {
const blockquote = schema.find(s => s.id === 'blockquote')!
const fontStyle = blockquote.properties.find(p => p.cssVar === 'blockquote-font-style')!
expect(fontStyle.inputType).toBe('select')
expect(fontStyle.options).toContain('normal')
expect(fontStyle.options).toContain('italic')
})
it('every var(--xxx) in EditorTheme.css has a matching default in theme schema or base UI', () => {
const cssPath = resolve(__dirname, '../components/EditorTheme.css')
const css = readFileSync(cssPath, 'utf-8')
// Extract all var(--xxx) references (first arg only, ignore fallbacks)
const varRegex = /var\(--([a-z0-9-]+)/g
const usedVars = new Set<string>()
let match: RegExpExecArray | null
while ((match = varRegex.exec(css)) !== null) {
usedVars.add(match[1])
}
// Collect all CSS var names from the schema
const schemaVars = new Set<string>()
for (const section of schema) {
for (const prop of section.properties) schemaVars.add(prop.cssVar)
for (const sub of section.subsections) {
for (const prop of sub.properties) schemaVars.add(prop.cssVar)
}
}
// Base UI color vars set by the theme color system (not in theme.json schema)
const baseUIVars = new Set([
'border-primary', 'bg-primary', 'bg-card', 'bg-hover-subtle', 'bg-selected',
'text-primary', 'text-secondary', 'text-muted', 'text-heading', 'text-tertiary',
'accent-blue',
])
for (const varName of usedVars) {
expect(
schemaVars.has(varName) || baseUIVars.has(varName),
).toBe(true)
}
})
})
describe('formatValueForFrontmatter', () => {
it('appends unit to numeric values', () => {
const prop: ThemeProperty = {
cssVar: 'editor-font-size', label: 'Font Size', defaultValue: 15,
inputType: 'number', unit: 'px', min: 0,
}
expect(formatValueForFrontmatter(15, prop)).toBe('15px')
})
it('does not append unit for unitless values', () => {
const prop: ThemeProperty = {
cssVar: 'editor-line-height', label: 'Line Height', defaultValue: 1.5,
inputType: 'number',
}
expect(formatValueForFrontmatter(1.5, prop)).toBe('1.5')
})
it('returns string values as-is', () => {
const prop: ThemeProperty = {
cssVar: 'editor-font-family', label: 'Font Family', defaultValue: 'Inter',
inputType: 'text',
}
expect(formatValueForFrontmatter('Helvetica', prop)).toBe('Helvetica')
})
})
describe('parseValueFromFrontmatter', () => {
it('extracts numeric value from string with unit', () => {
const prop: ThemeProperty = {
cssVar: 'editor-font-size', label: 'Font Size', defaultValue: 15,
inputType: 'number', unit: 'px',
}
expect(parseValueFromFrontmatter('15px', prop)).toBe(15)
})
it('returns string for non-numeric values', () => {
const prop: ThemeProperty = {
cssVar: 'editor-font-family', label: 'Font Family', defaultValue: 'Inter',
inputType: 'text',
}
expect(parseValueFromFrontmatter('Helvetica', prop)).toBe('Helvetica')
})
it('parses bare numbers', () => {
const prop: ThemeProperty = {
cssVar: 'editor-line-height', label: 'Line Height', defaultValue: 1.5,
inputType: 'number',
}
expect(parseValueFromFrontmatter('1.6', prop)).toBe(1.6)
})
})

View File

@@ -1,193 +0,0 @@
import themeConfig from '../theme.json'
import { isValidCssColor } from './colorUtils'
export type InputType = 'number' | 'color' | 'text' | 'select'
export interface ThemeProperty {
/** Flat kebab-case key used in frontmatter, e.g. "editor-font-size" */
cssVar: string
/** Human-readable label, e.g. "Font Size" */
label: string
/** Default value from theme.json */
defaultValue: string | number
inputType: InputType
/** Unit label shown next to numeric inputs (e.g. "px"). Absent for unitless. */
unit?: string
/** Options for select inputs (e.g. font weights). */
options?: string[]
/** Minimum allowed value for numeric inputs. */
min?: number
}
export interface ThemeSubsection {
id: string
label: string
properties: ThemeProperty[]
}
export interface ThemeSection {
id: string
label: string
properties: ThemeProperty[]
subsections: ThemeSubsection[]
}
const SECTION_LABELS: Record<string, string> = {
editor: 'Typography',
headings: 'Headings',
lists: 'Lists',
checkboxes: 'Checkboxes',
inlineStyles: 'Inline Styles',
codeBlocks: 'Code Blocks',
blockquote: 'Blockquote',
table: 'Table',
horizontalRule: 'Horizontal Rule',
colors: 'Colors',
}
const SUBSECTION_LABELS: Record<string, string> = {
h1: 'Heading 1',
h2: 'Heading 2',
h3: 'Heading 3',
h4: 'Heading 4',
bold: 'Bold',
italic: 'Italic',
strikethrough: 'Strikethrough',
code: 'Inline Code',
link: 'Link',
wikilink: 'Wiki Link',
}
/** Keys where the numeric value is unitless (ratios, weights). */
const UNITLESS_KEYS = /weight|lineHeight|opacity/i
/** Keys that should use a select input with predefined options. */
const SELECT_OPTIONS: Record<string, string[]> = {
fontWeight: ['400', '500', '600', '700'],
fontStyle: ['normal', 'italic'],
textDecoration: ['none', 'underline', 'line-through'],
cursor: ['default', 'pointer', 'text'],
}
function camelToKebab(str: string): string {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
}
function camelToTitle(str: string): string {
return str
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, c => c.toUpperCase())
}
function isColorValue(value: unknown, key: string): boolean {
if (typeof value !== 'string') return false
if (value.startsWith('#') || value.startsWith('var(--')) return isColorKeyHint(key) || isValidCssColor(value)
return false
}
function isColorKeyHint(key: string): boolean {
const lower = key.toLowerCase()
return lower === 'color' || lower.endsWith('color') || lower === 'background'
|| lower.endsWith('background') || lower === 'fill' || lower === 'tint'
}
function deriveInputType(key: string, value: unknown): { inputType: InputType; unit?: string; options?: string[]; min?: number } {
// Select options take priority
for (const [pattern, opts] of Object.entries(SELECT_OPTIONS)) {
if (key === pattern || key.endsWith(pattern.charAt(0).toUpperCase() + pattern.slice(1))) {
// Check if current key ends with the select key (e.g. "fontWeight" matches "boldFontWeight")
if (key === pattern || key.toLowerCase().endsWith(pattern.toLowerCase())) {
return { inputType: 'select', options: opts }
}
}
}
if (typeof value === 'number') {
const isUnitless = UNITLESS_KEYS.test(key)
return { inputType: 'number', unit: isUnitless ? undefined : 'px', min: 0 }
}
if (isColorValue(value, key)) {
return { inputType: 'color' }
}
return { inputType: 'text' }
}
function buildProperty(parentPrefix: string, key: string, value: string | number): ThemeProperty {
const cssVar = `${parentPrefix}${camelToKebab(key)}`
const { inputType, unit, options, min } = deriveInputType(key, value)
return { cssVar, label: camelToTitle(key), defaultValue: value, inputType, unit, options, min }
}
/** Build the full theme schema from theme.json, grouped by section. */
export function buildThemeSchema(): ThemeSection[] {
const sections: ThemeSection[] = []
for (const [sectionKey, sectionValue] of Object.entries(themeConfig)) {
if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) continue
const sectionObj = sectionValue as Record<string, unknown>
const sectionPrefix = `${camelToKebab(sectionKey)}-`
const section: ThemeSection = {
id: sectionKey,
label: SECTION_LABELS[sectionKey] ?? camelToTitle(sectionKey),
properties: [],
subsections: [],
}
for (const [key, value] of Object.entries(sectionObj)) {
if (Array.isArray(value)) continue // skip arrays like nestedBulletSymbols
if (typeof value === 'object' && value !== null) {
// Subsection (e.g. headings.h1, inlineStyles.bold)
const subPrefix = `${sectionPrefix}${camelToKebab(key)}-`
const subProperties: ThemeProperty[] = []
for (const [subKey, subValue] of Object.entries(value as Record<string, unknown>)) {
if (typeof subValue === 'string' || typeof subValue === 'number') {
subProperties.push(buildProperty(subPrefix, subKey, subValue))
}
}
if (subProperties.length > 0) {
section.subsections.push({
id: key,
label: SUBSECTION_LABELS[key] ?? camelToTitle(key),
properties: subProperties,
})
}
} else if (typeof value === 'string' || typeof value === 'number') {
section.properties.push(buildProperty(sectionPrefix, key, value))
}
}
if (section.properties.length > 0 || section.subsections.length > 0) {
sections.push(section)
}
}
return sections
}
/** Format a value for storage in theme note frontmatter. */
export function formatValueForFrontmatter(value: string | number, property: ThemeProperty): string {
if (property.inputType === 'number' && property.unit && typeof value === 'number') {
return `${value}${property.unit}`
}
return String(value)
}
/** Parse a frontmatter value back to its editable form (strip unit suffix). */
export function parseValueFromFrontmatter(raw: string, property: ThemeProperty): string | number {
if (property.inputType === 'number') {
const numeric = parseFloat(raw)
if (!isNaN(numeric)) return numeric
}
return raw
}
/** Cached schema — built once from theme.json. */
let cachedSchema: ThemeSection[] | null = null
export function getThemeSchema(): ThemeSection[] {
if (!cachedSchema) cachedSchema = buildThemeSchema()
return cachedSchema
}

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { isNoteWindow, getNoteWindowParams } from './windowMode'
describe('windowMode', () => {
let originalSearch: string
beforeEach(() => {
originalSearch = window.location.search
})
afterEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, search: originalSearch },
})
})
function setSearch(search: string) {
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, search },
})
}
describe('isNoteWindow', () => {
it('returns false when no query params', () => {
setSearch('')
expect(isNoteWindow()).toBe(false)
})
it('returns true when window=note', () => {
setSearch('?window=note&path=/test.md&vault=/vault')
expect(isNoteWindow()).toBe(true)
})
it('returns false for other window values', () => {
setSearch('?window=main')
expect(isNoteWindow()).toBe(false)
})
})
describe('getNoteWindowParams', () => {
it('returns null when not a note window', () => {
setSearch('')
expect(getNoteWindowParams()).toBeNull()
})
it('returns null when path is missing', () => {
setSearch('?window=note&vault=/vault')
expect(getNoteWindowParams()).toBeNull()
})
it('returns null when vault is missing', () => {
setSearch('?window=note&path=/test.md')
expect(getNoteWindowParams()).toBeNull()
})
it('returns params when all are present', () => {
setSearch('?window=note&path=%2Fvault%2Ftest.md&vault=%2Fvault&title=My%20Note')
expect(getNoteWindowParams()).toEqual({
notePath: '/vault/test.md',
vaultPath: '/vault',
noteTitle: 'My Note',
})
})
it('defaults title to Untitled', () => {
setSearch('?window=note&path=/test.md&vault=/vault')
const params = getNoteWindowParams()
expect(params?.noteTitle).toBe('Untitled')
})
})
})

24
src/utils/windowMode.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Detects whether the current window is a secondary "note window" (opened via
* "Open in New Window") by inspecting URL query parameters.
*/
export interface NoteWindowParams {
notePath: string
vaultPath: string
noteTitle: string
}
export function isNoteWindow(): boolean {
return new URLSearchParams(window.location.search).get('window') === 'note'
}
export function getNoteWindowParams(): NoteWindowParams | null {
const params = new URLSearchParams(window.location.search)
if (params.get('window') !== 'note') return null
const notePath = params.get('path')
const vaultPath = params.get('vault')
const noteTitle = params.get('title') ?? 'Untitled'
if (!notePath || !vaultPath) return null
return { notePath, vaultPath, noteTitle }
}

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