Compare commits
111 Commits
v0.2026022
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba7d2f1acc | ||
|
|
7e3c8630a4 | ||
|
|
d5b621e174 | ||
|
|
24c4a5a823 | ||
|
|
ea61ded4af | ||
|
|
3a623d48bb | ||
|
|
3fcb06396a | ||
|
|
17eeac75cd | ||
|
|
2dad764ea7 | ||
|
|
c3fa296b99 | ||
|
|
6370b66e05 | ||
|
|
1ec27dd264 | ||
|
|
10e6d7b366 | ||
|
|
0c87e51037 | ||
|
|
7df1961172 | ||
|
|
ec74f86d53 | ||
|
|
d86dfbfcb6 | ||
|
|
e77208ec34 | ||
|
|
818707603e | ||
|
|
fa3f9adccc | ||
|
|
bf6312000e | ||
|
|
832181b9a9 | ||
|
|
bc011692cd | ||
|
|
0604a13cdd | ||
|
|
eb77607401 | ||
|
|
e305c29e6e | ||
|
|
5b1804e5e1 | ||
|
|
2c5cfe2923 | ||
|
|
35144aedfb | ||
|
|
4d7252c78f | ||
|
|
eb9a3d889f | ||
|
|
6d2988b722 | ||
|
|
a33a000c57 | ||
|
|
f0b456bb8c | ||
|
|
b489fa8e3e | ||
|
|
1a92b4694c | ||
|
|
6b9ff9a4a2 | ||
|
|
97d2182c0e | ||
|
|
80baa74175 | ||
|
|
f71e6d07e7 | ||
|
|
b8a0702e3c | ||
|
|
c48f337c4d | ||
|
|
b32d46f482 | ||
|
|
fd100e1c3b | ||
|
|
249db95c9e | ||
|
|
3c27b63908 | ||
|
|
a246de4483 | ||
|
|
2793024904 | ||
|
|
f0ef9cacec | ||
|
|
d2538e121f | ||
|
|
531666b031 | ||
|
|
e239d71a48 | ||
|
|
e2f1fe239e | ||
|
|
8495f0e2e6 | ||
|
|
19bc3c67e3 | ||
|
|
628d97d909 | ||
|
|
aa6b31317c | ||
|
|
b24ba8a4c6 | ||
|
|
b3f47f4507 | ||
|
|
6fcd0552d0 | ||
|
|
8747a84453 | ||
|
|
2eb3080050 | ||
|
|
99b64c0fac | ||
|
|
b2a68a9a67 | ||
|
|
f5bbcb81a4 | ||
|
|
db5e53f77e | ||
|
|
90e67adc41 | ||
|
|
185feb5a2c | ||
|
|
f04dfdbd37 | ||
|
|
3c27403f86 | ||
|
|
276b3c1a37 | ||
|
|
cadb3500f1 | ||
|
|
ee8f0d6bcd | ||
|
|
41d43501a0 | ||
|
|
32b8e2ee57 | ||
|
|
b1110ead87 | ||
|
|
b75b917538 | ||
|
|
24bb64841e | ||
|
|
df2452dcaf | ||
|
|
89d0e39ad2 | ||
|
|
e3977a6042 | ||
|
|
4cdc534c01 | ||
|
|
1a93acdce7 | ||
|
|
9c8f7907b8 | ||
|
|
8d9862ffef | ||
|
|
a5e4efcf86 | ||
|
|
e4bab72952 | ||
|
|
f08b807a0c | ||
|
|
b521736102 | ||
|
|
4d13628d0b | ||
|
|
2b6000f5d4 | ||
|
|
894cb45779 | ||
|
|
2a1b17f8c6 | ||
|
|
47ca3cb13c | ||
|
|
5fb059d2e9 | ||
|
|
bd7feb94e7 | ||
|
|
de7b624902 | ||
|
|
7e1057482e | ||
|
|
7aa4bf7efe | ||
|
|
6e99bf7d03 | ||
|
|
669d473a64 | ||
|
|
ba1404b808 | ||
|
|
98314c5106 | ||
|
|
bac5a911c1 | ||
|
|
67155db9ab | ||
|
|
b42a4d3608 | ||
|
|
e29be460c3 | ||
|
|
bcfbc8e00e | ||
|
|
95bcf3b25a | ||
|
|
3fff794d9b | ||
|
|
de6023547f |
42
.claude-done
@@ -1,42 +0,0 @@
|
||||
# Drag & Drop Images — Implementation Summary
|
||||
|
||||
## Problem
|
||||
Dragging image files from Finder into the BlockNote editor didn't work. Tauri v2's
|
||||
`dragDropEnabled` defaults to `true`, which intercepts OS-level file drops before they
|
||||
reach the webview's HTML5 DnD API — BlockNote never received the dropped files.
|
||||
|
||||
## Solution
|
||||
Instead of disabling Tauri's drag interception, we use Tauri's `onDragDropEvent` API
|
||||
to listen for native file drops and handle them directly:
|
||||
|
||||
1. **Rust backend** (`src-tauri/src/vault/image.rs`):
|
||||
- Added `copy_image_to_vault` command that copies a file by OS path into
|
||||
`vault/attachments/` with a timestamp-prefixed filename
|
||||
- Refactored shared path logic into `prepare_attachment_path` helper
|
||||
- Validates file exists and has an image extension before copying
|
||||
|
||||
2. **Frontend** (`src/hooks/useImageDrop.ts`):
|
||||
- Added Tauri `onDragDropEvent` listener that fires on native OS drag-drop
|
||||
- On `drop`: filters for image paths, copies each to vault, calls `onImageUrl`
|
||||
- On `over`: shows drag overlay (paths aren't available until drop)
|
||||
- `copyImageToVault` invokes the new Rust command and returns an asset URL
|
||||
|
||||
3. **Editor integration** (`src/components/SingleEditorView.tsx`):
|
||||
- `useInsertImageCallback` inserts a BlockNote image block at cursor position
|
||||
- Wired into `useImageDrop` via the `onImageUrl` callback
|
||||
- `vaultPath` threaded through Editor → EditorContent → SingleEditorView
|
||||
|
||||
4. **Existing `/image` slash command** continues to work unchanged (uses `uploadFile`
|
||||
on `useCreateBlockNote` → `uploadImageFile` → `save_image` Tauri command).
|
||||
|
||||
## Commits
|
||||
- `d45f2e2` feat: add copy_image_to_vault Rust command for native drag-drop
|
||||
- `b047d0c` feat: handle Tauri native drag-drop for filesystem images
|
||||
- `2e04f5a` fix: remove nonexistent drag-drop permission from capabilities
|
||||
- `23ad982` fix: correct DragDropEvent type handling for 'over' events
|
||||
|
||||
## Tests
|
||||
- 4 new Rust tests for `copy_image_to_vault` (success, missing file, non-image, all extensions)
|
||||
- 1 new frontend test for `useImageDrop` with onImageUrl + vaultPath params
|
||||
- All 300 Rust tests pass, 1167 frontend tests pass
|
||||
- Coverage: Rust 85.47%, Frontend 78.64%
|
||||
1
.claude-pid
Normal file
@@ -0,0 +1 @@
|
||||
45402
|
||||
5
.github/workflows/ci.yml
vendored
@@ -60,6 +60,9 @@ jobs:
|
||||
- name: Run frontend tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Bundle MCP server resources (required by Tauri build)
|
||||
run: node scripts/bundle-mcp-server.mjs
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --manifest-path=src-tauri/Cargo.toml
|
||||
|
||||
@@ -72,8 +75,10 @@ jobs:
|
||||
run: |
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
|
||||
--fail-under-lines 85
|
||||
# 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
|
||||
|
||||
3
.gitignore
vendored
@@ -42,3 +42,6 @@ final_selection.py
|
||||
.claude-done
|
||||
.claude-blocked
|
||||
src-tauri/target
|
||||
|
||||
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
|
||||
src-tauri/resources/
|
||||
|
||||
@@ -85,6 +85,7 @@ if [ "$RUST_CHANGED" = true ]; then
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
$LLVM_COV_FLAGS \
|
||||
--ignore-filename-regex "search\.rs|lib\.rs" \
|
||||
--fail-under-lines 85 \
|
||||
-- --test-threads=1
|
||||
echo " ✅ Rust coverage OK"
|
||||
|
||||
18
CLAUDE.md
@@ -17,6 +17,10 @@ pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix stru
|
||||
|
||||
## ⛔ BEFORE FIRING laputa-task-done — QA on real vault
|
||||
|
||||
> **⚠️ TAURI APP ONLY — never test in browser (`pnpm dev` / localhost)**
|
||||
> The browser dev server has a fake HTTP server (`/api/*` routes, mock handlers) that doesn't exist in the real Tauri app.
|
||||
> If it works in the browser but crashes in Tauri, it's broken. Always test in `pnpm tauri dev`.
|
||||
|
||||
1. Acquire lockfile: `echo $$ > /tmp/laputa-qa.lock && trap "rm -f /tmp/laputa-qa.lock" EXIT`
|
||||
2. Kill other instances: `pkill -x laputa 2>/dev/null || true; sleep 1`
|
||||
3. Start app: `pnpm tauri dev` from worktree
|
||||
@@ -114,6 +118,17 @@ bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/click.sh 400 300 # logical coords
|
||||
```
|
||||
|
||||
## Keyboard-First Principle (mandatory for every new feature)
|
||||
|
||||
Every feature must be reachable via keyboard. This is both a UX requirement and a QA requirement — Brian tests the native app using keyboard only (osascript key events, no mouse).
|
||||
|
||||
**Before marking any task done:**
|
||||
- Can the feature be triggered/used without touching the mouse?
|
||||
- If it requires clicking a button, add a command palette entry or keyboard shortcut
|
||||
- Document the shortcut in the command palette or menu bar
|
||||
|
||||
**If you add UI that is only reachable by mouse**, you must also add a keyboard path (command palette entry, shortcut, or Tab-navigable focus). No exceptions.
|
||||
|
||||
## Push Workflow (IMPORTANT — changed Feb 27, 2026)
|
||||
|
||||
**Push directly to main** — no PRs, no branches, no CI queue.
|
||||
@@ -125,6 +140,9 @@ The pre-push hook runs all checks locally before the push goes through. This rep
|
||||
git push origin main # pre-push hook runs automatically
|
||||
```
|
||||
|
||||
### ⛔ NEVER open a Pull Request
|
||||
PRs on separate branches diverge from main with every merge, requiring continuous rebases and creating unnecessary conflicts. Always push directly to main. If the push fails (disk full, test failure, etc.) — fix the problem, then push again. There is no scenario where opening a PR is the right fallback.
|
||||
|
||||
### ⛔ NEVER use --no-verify
|
||||
```bash
|
||||
# FORBIDDEN — will be caught and rejected:
|
||||
|
||||
1
design/ai-agent-wiring.pen
Normal file
1
design/archived-note-indicator.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
96
design/build-number-status-bar.pen
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "build_number_status_bar",
|
||||
"name": "Build Number — Status Bar",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1200,
|
||||
"height": 36,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
0,
|
||||
16
|
||||
],
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_vault",
|
||||
"content": "vault-name",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_sep1",
|
||||
"content": "|",
|
||||
"fill": "$--border",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_build",
|
||||
"content": "b223",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "status_note",
|
||||
"content": "← dynamic build number from package.json, replacing hardcoded v0.4.2",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "build_number_fallback",
|
||||
"name": "Build Number — Fallback (no build info)",
|
||||
"x": 0,
|
||||
"y": 60,
|
||||
"width": 400,
|
||||
"height": 36,
|
||||
"fill": "$--background",
|
||||
"layout": "horizontal",
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
0,
|
||||
16
|
||||
],
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fallback_build",
|
||||
"content": "b?",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "fallback_note",
|
||||
"content": "← shows b? when build number unavailable",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontStyle": "italic"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
design/gs-git-init.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[{"type":"frame","id":"gs_git_init_after","name":"Getting Started Vault — Git Initialized","x":0,"y":0,"width":600,"height":"fit_content(200)","fill":"#FFFFFF","layout":"vertical","gap":16,"padding":[32,32],"children":[{"type":"text","id":"gs_title","content":"After: Getting Started vault created","fontFamily":"Inter","fontSize":20,"fontWeight":"700","fill":"#111111"},{"type":"text","id":"gs_desc","content":"When a new Getting Started vault is created, it is automatically initialized as a git repo with an initial commit containing all sample files.","fontFamily":"Inter","fontSize":14,"lineHeight":1.5,"fill":"#555555"},{"type":"frame","id":"gs_terminal","name":"Git Log Output","width":"fill_container","height":"fit_content(80)","fill":"#1E1E1E","cornerRadius":[8,8,8,8],"padding":[16,16],"layout":"vertical","gap":4,"children":[{"type":"text","id":"gs_prompt","content":"$ git log --oneline","fontFamily":"JetBrains Mono, monospace","fontSize":12,"fill":"#AAAAAA"},{"type":"text","id":"gs_log","content":"a1b2c3d Initial vault setup","fontFamily":"JetBrains Mono, monospace","fontSize":12,"fill":"#66FF66"}]},{"type":"frame","id":"gs_details","name":"Details","width":"fill_container","layout":"vertical","gap":8,"children":[{"type":"text","id":"gs_detail1","content":"git init + git add . + git commit","fontFamily":"Inter","fontSize":13,"fill":"#333333"},{"type":"text","id":"gs_detail2","content":"Fallback author: Laputa <vault@laputa.app> (if no global git config)","fontFamily":"Inter","fontSize":13,"fill":"#333333"},{"type":"text","id":"gs_detail3","content":"No UI changes — backend only (git.rs + getting_started.rs)","fontFamily":"Inter","fontSize":13,"fill":"#333333"}]}]}],"variables":{}}
|
||||
17
design/gs-wikilinks-fix.pen
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"name": "Getting Started Wikilinks — Fixed (styled with type color)",
|
||||
"type": "FRAME",
|
||||
"width": 800,
|
||||
"height": 400,
|
||||
"children": [
|
||||
{
|
||||
"name": "Description",
|
||||
"type": "TEXT",
|
||||
"characters": "Wikilinks in Getting Started vault now resolve correctly:\n- path/to/note targets match entry paths ending in /path/to/note.md\n- Display text shows entry title (not raw path)\n- Color reflects the linked note's type (e.g. blue for Note type)\n- Broken links show muted color as expected\n\nBefore: [[note/welcome-to-laputa]] showed raw path, unstyled\nAfter: [[note/welcome-to-laputa]] shows 'Welcome to Laputa' in Note blue"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
156
design/keyboard-first-nav.pen
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_notelist_nav",
|
||||
"name": "Keyboard-first — Note list keyboard navigation",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 280,
|
||||
"height": "fit_content(400)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 0,
|
||||
"padding": [8, 0],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_notelist_label",
|
||||
"content": "Note list — ↑↓ highlight, Enter opens",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500",
|
||||
"padding": [0, 12, 8, 12]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_note_item_selected",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"fill": "$--accent",
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_note_title_active",
|
||||
"content": "My selected note (keyboard highlight)",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_note_item_normal",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"fill": "transparent",
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_note_title_normal",
|
||||
"content": "Another note",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_menu_bar",
|
||||
"name": "Keyboard-first — Menu bar shortcuts added",
|
||||
"x": 320,
|
||||
"y": 0,
|
||||
"width": 400,
|
||||
"height": "fit_content(200)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"padding": [16, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_menu_title",
|
||||
"content": "New menu bar shortcuts",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_s1",
|
||||
"content": "Archive Note — ⌘E\nTrash Note — ⌘⌫\nFind in Vault — ⌘⇧F\nGo Back — ⌘[\nGo Forward — ⌘]",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 13,
|
||||
"lineHeight": 1.8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_inspector_tab",
|
||||
"name": "Keyboard-first — Inspector Tab+Enter navigation",
|
||||
"x": 760,
|
||||
"y": 0,
|
||||
"width": 300,
|
||||
"height": "fit_content(200)",
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"padding": [16, 16],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_inspector_title",
|
||||
"content": "Inspector — Tab focuses rows, Enter starts editing",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "kfn_prop_focused",
|
||||
"layout": "horizontal",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"fill": "$--muted",
|
||||
"cornerRadius": 4,
|
||||
"padding": [0, 12],
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_prop_key",
|
||||
"content": "Status",
|
||||
"fill": "$--muted-foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kfn_prop_val",
|
||||
"content": "In Progress (focused — ring visible)",
|
||||
"fill": "$--foreground",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
design/note-templates.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/raw-editor-mode.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/search-bundle-qmd.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[{"id":"status-bar-indexing","type":"frame","name":"StatusBar - Indexing Progress States","layout":"vertical","x":0,"y":0,"width":1400,"height":400,"gap":24,"padding":24,"fill":"#F7F6F3","children":[{"id":"title","type":"text","content":"StatusBar Indexing Progress - search-bundle-qmd","fontSize":20,"fontWeight":"600","fill":"#37352F"},{"id":"desc","type":"text","content":"The indexing badge appears in the StatusBar left section, after the pending changes badge. It shows the current indexing phase with a spinner icon (Loader2) during active phases and a Search icon when complete.","fontSize":14,"fill":"#9B9A97","width":900},{"id":"states","type":"frame","name":"States","layout":"vertical","gap":16,"width":1350,"children":[{"id":"state-idle","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-idle","type":"text","content":"idle:","fontSize":12,"fontWeight":"600","fill":"#9B9A97","width":100},{"id":"val-idle","type":"text","content":"(hidden - no badge shown)","fontSize":12,"fill":"#9B9A97"}]},{"id":"state-installing","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-install","type":"text","content":"installing:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-install","type":"text","content":"| [spinner] Installing search\u2026","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-scanning","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-scan","type":"text","content":"scanning:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-scan","type":"text","content":"| [spinner] Indexing\u2026 342/1,057","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-embedding","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-embed","type":"text","content":"embedding:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-embed","type":"text","content":"| [spinner] Embedding\u2026 50/200","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-complete","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-done","type":"text","content":"complete:","fontSize":12,"fontWeight":"600","fill":"#22c55e","width":100},{"id":"val-done","type":"text","content":"| [search icon] Index ready (auto-dismisses after 5s)","fontSize":12,"fill":"#22c55e"}]},{"id":"state-error","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-err","type":"text","content":"error:","fontSize":12,"fontWeight":"600","fill":"#f97316","width":100},{"id":"val-err","type":"text","content":"| [search icon] Index error (orange accent)","fontSize":12,"fill":"#f97316"}]}]},{"id":"flow","type":"frame","name":"Flow","layout":"vertical","gap":8,"width":1350,"children":[{"id":"flow-title","type":"text","content":"Auto-indexing Flow","fontSize":16,"fontWeight":"600","fill":"#37352F"},{"id":"flow-desc","type":"text","content":"1. Vault opens \u2192 useIndexing checks index status via get_index_status\n2. If qmd missing or collection missing or pending embeds \u2192 triggers start_indexing\n3. Rust emits indexing-progress events (installing \u2192 scanning \u2192 embedding \u2192 complete)\n4. StatusBar shows IndexingBadge with spinner + phase label + progress counts\n5. On file save \u2192 trigger_incremental_index runs qmd update in background\n6. Complete phase auto-dismisses after 5 seconds","fontSize":13,"fill":"#37352F","width":900}]}]}],"variables":{}}
|
||||
1
design/sort-picker-properties.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/sync-conflict-resolution.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/tab-responsive-width.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/themes-editable.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/theming-system.pen
Normal file
1
design/trashed-note-editor.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[{"id":"trashed-banner-frame","type":"frame","name":"Trashed Note Banner","x":0,"y":0,"width":720,"height":340,"fill":"#FFFFFF","cornerRadius":[8,8,8,8],"children":[{"id":"breadcrumb","type":"frame","name":"Breadcrumb Bar","width":720,"height":45,"fill":"#FFFFFF","layout":"horizontal","mainAxisAlignment":"space-between","crossAxisAlignment":"center","padding":16,"children":[{"id":"breadcrumb-left","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","children":[{"id":"type-label","type":"text","content":"Note","fontSize":12,"textColor":"#6B7280"},{"id":"sep","type":"text","content":"›","fontSize":12,"textColor":"#6B7280"},{"id":"title","type":"text","content":"My Trashed Note","fontSize":12,"fontWeight":"600","textColor":"#1F2937"},{"id":"dot","type":"text","content":"·","fontSize":12,"textColor":"#6B7280"},{"id":"words","type":"text","content":"1,234 words","fontSize":12,"textColor":"#6B7280"}]},{"id":"breadcrumb-right","type":"frame","layout":"horizontal","gap":12,"crossAxisAlignment":"center","children":[{"id":"restore-icon","type":"text","content":"↺","fontSize":14,"textColor":"#6B7280"},{"id":"inspector-icon","type":"text","content":"⚙","fontSize":14,"textColor":"#6B7280"}]}]},{"id":"banner","type":"frame","name":"Trashed Note Banner","width":720,"height":32,"fill":"#FEF2F2","layout":"horizontal","crossAxisAlignment":"center","gap":8,"padding":16,"children":[{"id":"trash-icon","type":"text","content":"🗑","fontSize":12,"textColor":"#DC2626"},{"id":"banner-text","type":"text","content":"This note is in the Trash","fontSize":12,"textColor":"#6B7280","width":400},{"id":"restore-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"restore-icon-2","type":"text","content":"↺","fontSize":11,"textColor":"#2563EB"},{"id":"restore-label","type":"text","content":"Restore","fontSize":11,"textColor":"#2563EB"}]},{"id":"delete-btn","type":"frame","layout":"horizontal","gap":4,"crossAxisAlignment":"center","padding":4,"cornerRadius":[4,4,4,4],"children":[{"id":"delete-icon","type":"text","content":"🗑","fontSize":11,"textColor":"#DC2626"},{"id":"delete-label","type":"text","content":"Delete permanently","fontSize":11,"textColor":"#DC2626"}]}]},{"id":"editor-area","type":"frame","name":"Editor (read-only, muted)","width":720,"height":260,"fill":"#FAFAFA","padding":32,"children":[{"id":"h1","type":"text","content":"My Trashed Note","fontSize":24,"fontWeight":"700","textColor":"#9CA3AF"},{"id":"p1","type":"text","content":"This is the content of a trashed note. The editor is non-editable.","fontSize":14,"textColor":"#9CA3AF","y":40},{"id":"p2","type":"text","content":"Navigation and copy still work, but typing and editing are disabled.","fontSize":14,"textColor":"#9CA3AF","y":64}]}]}],"variables":{}}
|
||||
1
design/vault-agents-md.pen
Normal file
281
design/vault-management-remove.pen
Normal file
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_menu_remove",
|
||||
"name": "Vault Menu — Remove from List",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 380,
|
||||
"height": 320,
|
||||
"fill": "#F7F6F3",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24, 24, 24],
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vault_menu_title",
|
||||
"content": "Vault Menu — Remove Action",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_menu_dropdown",
|
||||
"name": "Vault Dropdown",
|
||||
"width": "fill_container",
|
||||
"height": "fit_content",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": [6, 6, 6, 6],
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"layout": "vertical",
|
||||
"padding": [4, 4, 4, 4],
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_active",
|
||||
"name": "Active Vault Item",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"fill": "#EBEBEA",
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [4, 8, 4, 8],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_active_left",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{ "type": "text", "id": "vault_check", "content": "✓", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 12 },
|
||||
{ "type": "text", "id": "vault_label_active", "content": "My Vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "remove_btn_active",
|
||||
"name": "Remove Button",
|
||||
"width": 18,
|
||||
"height": 18,
|
||||
"cornerRadius": [3, 3, 3, 3],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "x_icon_active", "content": "×", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_other",
|
||||
"name": "Other Vault Item",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [4, 8, 4, 8],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_item_other_left",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{ "type": "text", "id": "vault_spacer", "content": " ", "fill": "transparent", "fontFamily": "Inter", "fontSize": 12 },
|
||||
{ "type": "text", "id": "vault_label_other", "content": "Work Vault", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "remove_btn_other",
|
||||
"name": "Remove Button",
|
||||
"width": 18,
|
||||
"height": 18,
|
||||
"cornerRadius": [3, 3, 3, 3],
|
||||
"layout": "vertical",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{ "type": "text", "id": "x_icon_other", "content": "×", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_separator",
|
||||
"name": "Separator",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#E9E9E7"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vault_open_folder",
|
||||
"name": "Open Local Folder",
|
||||
"width": "fill_container",
|
||||
"height": 32,
|
||||
"cornerRadius": [4, 4, 4, 4],
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"gap": 6,
|
||||
"padding": [4, 8, 4, 8],
|
||||
"children": [
|
||||
{ "type": "text", "id": "folder_icon", "content": "📁", "fontFamily": "Inter", "fontSize": 12 },
|
||||
{ "type": "text", "id": "open_folder_label", "content": "Open local folder", "fill": "#787774", "fontFamily": "Inter", "fontSize": 12 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "vault_menu_note",
|
||||
"content": "× button removes vault from list without deleting files.\nAvailable when 2+ vaults in list.",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmd_k_vault_commands",
|
||||
"name": "Cmd+K — Vault Commands",
|
||||
"x": 420,
|
||||
"y": 0,
|
||||
"width": 520,
|
||||
"height": 320,
|
||||
"fill": "#F7F6F3",
|
||||
"layout": "vertical",
|
||||
"gap": 16,
|
||||
"padding": [24, 24, 24, 24],
|
||||
"theme": { "Mode": "Light" },
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cmdk_title",
|
||||
"content": "Command Palette — Vault Commands",
|
||||
"fill": "#37352F",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "600"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_palette",
|
||||
"name": "Command Palette",
|
||||
"width": "fill_container",
|
||||
"height": "fit_content",
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": [8, 8, 8, 8],
|
||||
"stroke": "#E9E9E7",
|
||||
"strokeThickness": 1,
|
||||
"layout": "vertical",
|
||||
"padding": [0, 0, 0, 0],
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_search",
|
||||
"name": "Search Input",
|
||||
"width": "fill_container",
|
||||
"height": 44,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 16, 0, 16],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmdk_search_text", "content": "vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 14 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_separator",
|
||||
"width": "fill_container",
|
||||
"height": 1,
|
||||
"fill": "#E9E9E7"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_group_header",
|
||||
"name": "Settings Group",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "group_label", "content": "Settings", "fill": "#B4B4B4", "fontFamily": "Inter", "fontSize": 11, "fontWeight": "500" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_open_vault",
|
||||
"name": "Open Vault Command",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmd_open_vault", "content": "Open Vault…", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_remove_vault",
|
||||
"name": "Remove Vault Command (highlighted)",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"fill": "#E8F4FE",
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmd_remove_vault", "content": "Remove Vault from List", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cmdk_restore_gs",
|
||||
"name": "Restore Getting Started Command",
|
||||
"width": "fill_container",
|
||||
"height": 36,
|
||||
"layout": "horizontal",
|
||||
"alignItems": "center",
|
||||
"padding": [0, 12, 0, 12],
|
||||
"children": [
|
||||
{ "type": "text", "id": "cmd_restore_gs", "content": "Restore Getting Started Vault", "fill": "#37352F", "fontFamily": "Inter", "fontSize": 13 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "cmdk_note",
|
||||
"content": "• 'Remove Vault from List' removes active vault (disabled if only 1 vault)\n• 'Restore Getting Started Vault' shown when GS vault is hidden\n• Both accessible via Cmd+K search with 'vault' keyword",
|
||||
"fill": "#787774",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"width": "fill_container"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
@@ -231,3 +231,30 @@ test('full create note flow', async ({ page }) => {
|
||||
|
||||
await page.screenshot({ path: 'test-results/core-create-note.png', fullPage: true })
|
||||
})
|
||||
|
||||
// --- Flow 8: Wiki-link navigation ---
|
||||
|
||||
test('clicking a wikilink opens the target note in a new tab', async ({ page }) => {
|
||||
// Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink
|
||||
await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verify we opened the right note
|
||||
await expect(page.locator('.editor__tab--active')).toHaveText(/Manage Sponsorships/)
|
||||
|
||||
// Click the wikilink — use mouse.click to fire real mousedown
|
||||
const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' })
|
||||
await expect(wikilink).toBeVisible()
|
||||
const box = await wikilink.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// New tab should open with the target note active
|
||||
await expect(page.locator('.editor__tab--active')).toHaveText(/Matteo Cellini/)
|
||||
|
||||
// Editor should show the target note's content
|
||||
await expect(page.locator('.cm-content')).toContainText('Matteo Cellini')
|
||||
|
||||
await page.screenshot({ path: 'test-results/core-wikilink-nav.png', fullPage: true })
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'coverage']),
|
||||
globalIgnores(['dist', 'coverage', 'src-tauri/resources/', 'src-tauri/target/']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
|
||||
@@ -32,10 +32,16 @@ import { startUiBridge } from './ws-bridge.js'
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
|
||||
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions
|
||||
const uiBridge = startUiBridge(WS_UI_PORT)
|
||||
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions.
|
||||
// If the port is already in use (e.g. by the running Laputa app), continue
|
||||
// without the bridge — vault tools still work via stdio MCP.
|
||||
let uiBridge = null
|
||||
startUiBridge(WS_UI_PORT).then((bridge) => {
|
||||
uiBridge = bridge
|
||||
})
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiBridge) return
|
||||
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
|
||||
for (const client of uiBridge.clients) {
|
||||
if (client.readyState === 1) client.send(msg)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* Protocol (UI bridge):
|
||||
* Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." }
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote,
|
||||
@@ -80,15 +81,34 @@ async function handleMessage(data) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to start the UI bridge WebSocket server.
|
||||
* Returns a Promise that resolves to the WebSocketServer or null if the port
|
||||
* is unavailable (e.g. another Laputa instance owns it).
|
||||
*/
|
||||
export function startUiBridge(port = WS_UI_PORT) {
|
||||
uiBridge = new WebSocketServer({ port })
|
||||
return new Promise((resolve) => {
|
||||
const httpServer = createServer()
|
||||
|
||||
uiBridge.on('connection', () => {
|
||||
console.error(`[ws-bridge] UI client connected on port ${port}`)
|
||||
httpServer.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[ws-bridge] UI bridge port ${port} already in use, disabling bridge`)
|
||||
} else {
|
||||
console.error(`[ws-bridge] UI bridge error: ${err.message}`)
|
||||
}
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
const wss = new WebSocketServer({ server: httpServer })
|
||||
wss.on('connection', () => {
|
||||
console.error(`[ws-bridge] UI client connected on port ${port}`)
|
||||
})
|
||||
uiBridge = wss
|
||||
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
|
||||
resolve(wss)
|
||||
})
|
||||
})
|
||||
|
||||
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
|
||||
return uiBridge
|
||||
}
|
||||
|
||||
export function startBridge(port = WS_PORT) {
|
||||
@@ -116,6 +136,5 @@ export function startBridge(port = WS_PORT) {
|
||||
// Run directly if invoked as main module
|
||||
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
|
||||
if (isMain) {
|
||||
startUiBridge()
|
||||
startBridge()
|
||||
startUiBridge().then(() => startBridge())
|
||||
}
|
||||
|
||||
11
package.json
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
@@ -20,6 +21,12 @@
|
||||
"@blocknote/core": "^0.46.2",
|
||||
"@blocknote/mantine": "^0.46.2",
|
||||
"@blocknote/react": "^0.46.2",
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.16",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -48,7 +55,10 @@
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
@@ -64,6 +74,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"esbuild": "^0.27.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
|
||||
1119
pnpm-lock.yaml
generated
@@ -1,2 +1,5 @@
|
||||
packages:
|
||||
- mcp-server
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
||||
45
scripts/bundle-mcp-server.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Bundle the mcp-server Node.js files into self-contained CJS bundles
|
||||
* that can be shipped as Tauri resources inside the .app bundle.
|
||||
*
|
||||
* Output: src-tauri/resources/mcp-server/{index.js,ws-bridge.js}
|
||||
*/
|
||||
import { build } from 'esbuild'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
import { mkdirSync, writeFileSync } from 'fs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = join(__dirname, '..')
|
||||
const SRC = join(ROOT, 'mcp-server')
|
||||
const OUT = join(ROOT, 'src-tauri', 'resources', 'mcp-server')
|
||||
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
|
||||
// Tell Node.js that this directory contains CJS bundles, even if the
|
||||
// root package.json declares "type": "module".
|
||||
writeFileSync(join(OUT, 'package.json'), JSON.stringify({ type: 'commonjs' }))
|
||||
|
||||
const shared = {
|
||||
platform: 'node',
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
// Mark optional native bindings as external — ws works fine without them
|
||||
external: ['bufferutil', 'utf-8-validate'],
|
||||
logLevel: 'warning',
|
||||
}
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
entryPoints: [join(SRC, 'index.js')],
|
||||
outfile: join(OUT, 'index.js'),
|
||||
})
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
entryPoints: [join(SRC, 'ws-bridge.js')],
|
||||
outfile: join(OUT, 'ws-bridge.js'),
|
||||
})
|
||||
|
||||
console.log('mcp-server bundled → src-tauri/resources/mcp-server/')
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 559 KiB After Width: | Height: | Size: 521 KiB |
|
Before Width: | Height: | Size: 665 B After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 881 B After Width: | Height: | Size: 815 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 10 KiB |
842
src-tauri/src/claude_cli.rs
Normal file
@@ -0,0 +1,842 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Status returned by `check_claude_cli`.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ClaudeCliStatus {
|
||||
pub installed: bool,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Event emitted to the frontend during a streaming claude session.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(tag = "kind")]
|
||||
pub enum ClaudeStreamEvent {
|
||||
/// Session initialised — carries the session ID for future `--resume`.
|
||||
Init { session_id: String },
|
||||
/// Incremental text chunk.
|
||||
TextDelta { text: String },
|
||||
/// A tool call started (agent mode only).
|
||||
ToolStart { tool_name: String, tool_id: String },
|
||||
/// A tool call finished (agent mode only).
|
||||
ToolDone { tool_id: String },
|
||||
/// Final result text + session ID.
|
||||
Result { text: String, session_id: String },
|
||||
/// Something went wrong.
|
||||
Error { message: String },
|
||||
/// Stream finished.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Parameters accepted by `stream_claude_chat`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters accepted by `stream_claude_agent`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Finding the `claude` binary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
|
||||
// Try `which claude` first (works when PATH is inherited).
|
||||
let output = Command::new("which")
|
||||
.arg("claude")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run `which claude`: {e}"))?;
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check common install locations.
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let candidates = [
|
||||
home.join(".local/bin/claude"),
|
||||
home.join(".npm/bin/claude"),
|
||||
PathBuf::from("/usr/local/bin/claude"),
|
||||
PathBuf::from("/opt/homebrew/bin/claude"),
|
||||
];
|
||||
for p in &candidates {
|
||||
if p.exists() {
|
||||
return Ok(p.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check whether the `claude` CLI is installed and return its version.
|
||||
pub fn check_cli() -> ClaudeCliStatus {
|
||||
let bin = match find_claude_binary() {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return ClaudeCliStatus {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let version = Command::new(&bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
|
||||
|
||||
ClaudeCliStatus {
|
||||
installed: true,
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `claude -p` for a simple chat (no tools) and stream events via the
|
||||
/// provided callback. Returns the session ID for future `--resume` calls.
|
||||
pub fn run_chat_stream<F>(req: ChatStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let bin = find_claude_binary()?;
|
||||
let args = build_chat_args(&req);
|
||||
run_claude_subprocess(&bin, &args, &mut emit)
|
||||
}
|
||||
|
||||
/// Build CLI arguments for a chat stream request.
|
||||
fn build_chat_args(req: &ChatStreamRequest) -> Vec<String> {
|
||||
let mut args: Vec<String> = vec![
|
||||
"-p".into(),
|
||||
req.message.clone(),
|
||||
"--output-format".into(),
|
||||
"stream-json".into(),
|
||||
"--verbose".into(),
|
||||
"--include-partial-messages".into(),
|
||||
"--tools".into(),
|
||||
String::new(), // empty string → disable all built-in tools
|
||||
];
|
||||
|
||||
if let Some(ref sp) = req.system_prompt {
|
||||
if !sp.is_empty() {
|
||||
args.push("--system-prompt".into());
|
||||
args.push(sp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref sid) = req.session_id {
|
||||
args.push("--resume".into());
|
||||
args.push(sid.clone());
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
/// Spawn `claude -p` with MCP vault tools for an agent task and stream events.
|
||||
pub fn run_agent_stream<F>(req: AgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let bin = find_claude_binary()?;
|
||||
let args = build_agent_args(&req)?;
|
||||
run_claude_subprocess(&bin, &args, &mut emit)
|
||||
}
|
||||
|
||||
/// Build CLI arguments for an agent stream request.
|
||||
fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_config = build_mcp_config(&req.vault_path)?;
|
||||
|
||||
let mut args: Vec<String> = vec![
|
||||
"-p".into(),
|
||||
req.message.clone(),
|
||||
"--output-format".into(),
|
||||
"stream-json".into(),
|
||||
"--verbose".into(),
|
||||
"--include-partial-messages".into(),
|
||||
"--tools".into(),
|
||||
String::new(), // disable built-in tools; MCP tools remain
|
||||
"--mcp-config".into(),
|
||||
mcp_config,
|
||||
"--dangerously-skip-permissions".into(),
|
||||
"--no-session-persistence".into(),
|
||||
];
|
||||
|
||||
if let Some(ref sp) = req.system_prompt {
|
||||
if !sp.is_empty() {
|
||||
args.push("--append-system-prompt".into());
|
||||
args.push(sp.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a temporary MCP config JSON string pointing to the vault MCP server.
|
||||
fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
let server_dir = crate::mcp::mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"laputa": {
|
||||
"command": "node",
|
||||
"args": [index_js.to_string_lossy()],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
}
|
||||
}
|
||||
});
|
||||
serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}"))
|
||||
}
|
||||
|
||||
/// Core subprocess runner shared by chat and agent modes.
|
||||
fn run_claude_subprocess<F>(bin: &PathBuf, args: &[String], emit: &mut F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let mut child = Command::new(bin)
|
||||
.args(args)
|
||||
.env_remove("CLAUDECODE") // prevent "nested session" guard
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
emit(ClaudeStreamEvent::Error {
|
||||
message: format!("Read error: {e}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue, // skip non-JSON lines
|
||||
};
|
||||
|
||||
dispatch_event(&json, &mut session_id, emit);
|
||||
}
|
||||
|
||||
// Read stderr for potential error messages.
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|s| std::io::read_to_string(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
|
||||
|
||||
if !status.success() && session_id.is_empty() {
|
||||
let msg = if stderr_output.contains("not logged in")
|
||||
|| stderr_output.contains("authentication")
|
||||
|| stderr_output.contains("auth")
|
||||
{
|
||||
"Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into()
|
||||
} else if stderr_output.is_empty() {
|
||||
format!("claude exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
};
|
||||
emit(ClaudeStreamEvent::Error { message: msg });
|
||||
}
|
||||
|
||||
emit(ClaudeStreamEvent::Done);
|
||||
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Parse a single JSON line from the stream and emit the appropriate event.
|
||||
fn dispatch_event<F>(json: &serde_json::Value, session_id: &mut String, emit: &mut F)
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let msg_type = json["type"].as_str().unwrap_or("");
|
||||
|
||||
match msg_type {
|
||||
// --- System init → capture session_id ---
|
||||
"system" if json["subtype"].as_str() == Some("init") => {
|
||||
if let Some(sid) = json["session_id"].as_str() {
|
||||
*session_id = sid.to_string();
|
||||
emit(ClaudeStreamEvent::Init {
|
||||
session_id: sid.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Streaming partial events (text deltas, tool_use starts) ---
|
||||
"stream_event" => {
|
||||
dispatch_stream_event(json, emit);
|
||||
}
|
||||
|
||||
// --- Tool progress (agent mode) ---
|
||||
"tool_progress" => {
|
||||
if let (Some(name), Some(id)) =
|
||||
(json["tool_name"].as_str(), json["tool_use_id"].as_str())
|
||||
{
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Final result ---
|
||||
"result" => {
|
||||
let sid = json["session_id"].as_str().unwrap_or("").to_string();
|
||||
if !sid.is_empty() {
|
||||
*session_id = sid.clone();
|
||||
}
|
||||
let text = json["result"].as_str().unwrap_or("").to_string();
|
||||
emit(ClaudeStreamEvent::Result {
|
||||
text,
|
||||
session_id: sid,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Complete assistant message (fallback for text when no partials) ---
|
||||
"assistant" => {
|
||||
if let Some(content) = json["message"]["content"].as_array() {
|
||||
for block in content {
|
||||
if block["type"].as_str() == Some("tool_use") {
|
||||
if let (Some(id), Some(name)) =
|
||||
(block["id"].as_str(), block["name"].as_str())
|
||||
{
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {} // ignore other event types
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a `stream_event` (partial assistant message).
|
||||
fn dispatch_stream_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let event = &json["event"];
|
||||
let event_type = event["type"].as_str().unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
"content_block_delta" => {
|
||||
let delta = &event["delta"];
|
||||
if delta["type"].as_str() == Some("text_delta") {
|
||||
if let Some(text) = delta["text"].as_str() {
|
||||
emit(ClaudeStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_block_start" => {
|
||||
let block = &event["content_block"];
|
||||
if block["type"].as_str() == Some("tool_use") {
|
||||
if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) {
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn check_cli_returns_status() {
|
||||
let status = check_cli();
|
||||
if status.installed {
|
||||
assert!(status.version.is_some());
|
||||
} else {
|
||||
assert!(status.version.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_mcp_config_is_valid_json() {
|
||||
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap();
|
||||
assert!(parsed["mcpServers"]["laputa"]["command"].is_string());
|
||||
assert_eq!(
|
||||
parsed["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
|
||||
"/tmp/test-vault"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- dispatch_event / dispatch_stream_event ---
|
||||
|
||||
/// Run dispatch_event on the given JSON and return (session_id, events).
|
||||
fn run_dispatch(json: serde_json::Value) -> (String, Vec<ClaudeStreamEvent>) {
|
||||
let mut sid = String::new();
|
||||
let mut events = vec![];
|
||||
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
|
||||
(sid, events)
|
||||
}
|
||||
|
||||
/// Run dispatch_event with a pre-set session_id.
|
||||
fn run_dispatch_with_sid(
|
||||
json: serde_json::Value,
|
||||
initial_sid: &str,
|
||||
) -> (String, Vec<ClaudeStreamEvent>) {
|
||||
let mut sid = initial_sid.to_string();
|
||||
let mut events = vec![];
|
||||
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
|
||||
(sid, events)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_init() {
|
||||
let (sid, events) = run_dispatch(serde_json::json!({
|
||||
"type": "system", "subtype": "init", "session_id": "test-session-123"
|
||||
}));
|
||||
assert_eq!(sid, "test-session-123");
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "test-session-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_system_without_init_subtype_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({ "type": "system", "subtype": "other" }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_system_init_without_session_id_is_ignored() {
|
||||
let (sid, events) =
|
||||
run_dispatch(serde_json::json!({ "type": "system", "subtype": "init" }));
|
||||
assert!(events.is_empty());
|
||||
assert!(sid.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_text_delta() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } }
|
||||
}));
|
||||
assert!(matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_tool_start() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "tool_abc", "name": "read_note", "input": {} } }
|
||||
}));
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "read_note" && tool_id == "tool_abc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_result() {
|
||||
let (sid, events) = run_dispatch(serde_json::json!({
|
||||
"type": "result", "subtype": "success", "result": "All done!", "session_id": "sess-456"
|
||||
}));
|
||||
assert_eq!(sid, "sess-456");
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::Result { text, session_id } if text == "All done!" && session_id == "sess-456")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_result_with_empty_session_id() {
|
||||
let (sid, events) = run_dispatch_with_sid(
|
||||
serde_json::json!({ "type": "result", "result": "text here" }),
|
||||
"prev-session",
|
||||
);
|
||||
assert_eq!(sid, "prev-session");
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "text here")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_tool_progress() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "tool_progress", "tool_name": "search_notes", "tool_use_id": "tool_xyz"
|
||||
}));
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tool_xyz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_tool_progress_missing_fields_is_ignored() {
|
||||
let (_, events) =
|
||||
run_dispatch(serde_json::json!({ "type": "tool_progress", "tool_name": "x" }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_assistant_with_tool_use() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "assistant",
|
||||
"message": { "content": [
|
||||
{ "type": "text", "text": "Let me search." },
|
||||
{ "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} }
|
||||
] }
|
||||
}));
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tu_1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_assistant_without_content_is_noop() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({ "type": "assistant", "message": {} }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_ignores_unknown() {
|
||||
let (_, events) =
|
||||
run_dispatch(serde_json::json!({ "type": "some_future_type", "data": 42 }));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stream_event_non_text_delta_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } }
|
||||
}));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stream_event_non_tool_block_start_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } }
|
||||
}));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stream_event_unknown_type_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event", "event": { "type": "message_stop" }
|
||||
}));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
// --- run_claude_subprocess with mock scripts ---
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_mock_script(script: &str) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
|
||||
run_mock_script_with_args(script, &[])
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_mock_script_with_args(
|
||||
script: &str,
|
||||
args: &[String],
|
||||
) -> (Result<String, String>, Vec<ClaudeStreamEvent>) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("mock-claude");
|
||||
std::fs::write(&path, script).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut events = vec![];
|
||||
let result = run_claude_subprocess(&path, args, &mut |e| events.push(e));
|
||||
(result, events)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_parses_ndjson_stream() {
|
||||
let (result, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s1\"}'\n",
|
||||
"echo '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}}'\n",
|
||||
"echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"s1\"}'\n",
|
||||
));
|
||||
assert_eq!(result.unwrap(), "s1");
|
||||
assert!(matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "s1"));
|
||||
assert!(matches!(&events[1], ClaudeStreamEvent::TextDelta { text } if text == "Hi"));
|
||||
assert!(matches!(&events[2], ClaudeStreamEvent::Result { .. }));
|
||||
assert!(matches!(&events[3], ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_skips_blank_and_non_json_lines() {
|
||||
let (result, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo ''\n",
|
||||
"echo 'not json at all'\n",
|
||||
"echo '{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"s2\"}'\n",
|
||||
));
|
||||
assert_eq!(result.unwrap(), "s2");
|
||||
assert!(matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "ok"));
|
||||
assert!(matches!(&events[1], ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_emits_error_on_nonzero_exit() {
|
||||
let (_, events) = run_mock_script("#!/bin/sh\necho 'auth problem' >&2\nexit 1\n");
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| matches!(e, ClaudeStreamEvent::Error { .. })));
|
||||
assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_detects_auth_error_in_stderr() {
|
||||
let (_, events) = run_mock_script("#!/bin/sh\necho 'not logged in' >&2\nexit 1\n");
|
||||
assert!(events.iter().any(|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("not authenticated"))));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_reports_exit_code_on_empty_stderr() {
|
||||
let (_, events) = run_mock_script("#!/bin/sh\nexit 2\n");
|
||||
assert!(events.iter().any(
|
||||
|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("exited with"))
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_success_with_no_events() {
|
||||
let (result, events) = run_mock_script("#!/bin/sh\nexit 0\n");
|
||||
assert!(result.is_ok());
|
||||
assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_passes_args_through() {
|
||||
let args: Vec<String> = vec!["--foo".into(), "bar".into()];
|
||||
let (_, events) = run_mock_script_with_args(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"$*\\\",\\\"session_id\\\":\\\"sx\\\"}\"\n",
|
||||
), &args);
|
||||
let text = events.iter().find_map(|e| match e {
|
||||
ClaudeStreamEvent::Result { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
});
|
||||
assert!(text.unwrap().contains("--foo"));
|
||||
}
|
||||
|
||||
// --- build_chat_args ---
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_basic() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hello".into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"hello".to_string()));
|
||||
assert!(args.contains(&"stream-json".to_string()));
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
assert!(!args.contains(&"--resume".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_system_prompt() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--system-prompt".to_string()));
|
||||
assert!(args.contains(&"You are helpful.".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_empty_system_prompt_is_skipped() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "hi".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
session_id: None,
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(!args.contains(&"--system-prompt".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_chat_args_with_session_id() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "continue".into(),
|
||||
system_prompt: None,
|
||||
session_id: Some("sess-abc".into()),
|
||||
};
|
||||
let args = build_chat_args(&req);
|
||||
assert!(args.contains(&"--resume".to_string()));
|
||||
assert!(args.contains(&"sess-abc".to_string()));
|
||||
}
|
||||
|
||||
// --- build_agent_args ---
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_basic() {
|
||||
// build_agent_args calls build_mcp_config which needs mcp_server_dir
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "create note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"-p".to_string()));
|
||||
assert!(args.contains(&"create note".to_string()));
|
||||
assert!(args.contains(&"--mcp-config".to_string()));
|
||||
assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(args.contains(&"--no-session-persistence".to_string()));
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_with_system_prompt() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "do it".into(),
|
||||
system_prompt: Some("Act as expert.".into()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
assert!(args.contains(&"--append-system-prompt".to_string()));
|
||||
assert!(args.contains(&"Act as expert.".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_agent_args_empty_system_prompt_is_skipped() {
|
||||
if let Ok(args) = build_agent_args(&AgentStreamRequest {
|
||||
message: "x".into(),
|
||||
system_prompt: Some(String::new()),
|
||||
vault_path: "/tmp/v".into(),
|
||||
}) {
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// --- find_claude_binary ---
|
||||
|
||||
#[test]
|
||||
fn find_claude_binary_returns_result() {
|
||||
let result = find_claude_binary();
|
||||
// On dev machines claude may be installed; on CI it may not.
|
||||
// Either way, the function should return Ok(path) or Err(message).
|
||||
match &result {
|
||||
Ok(path) => assert!(path.exists()),
|
||||
Err(msg) => assert!(msg.contains("not found")),
|
||||
}
|
||||
}
|
||||
|
||||
// --- run_chat_stream / run_agent_stream error paths ---
|
||||
|
||||
#[test]
|
||||
fn run_chat_stream_returns_result() {
|
||||
let req = ChatStreamRequest {
|
||||
message: "test".into(),
|
||||
system_prompt: None,
|
||||
session_id: None,
|
||||
};
|
||||
let mut events = vec![];
|
||||
// This will either succeed (if claude is installed) or fail (if not).
|
||||
let result = run_chat_stream(req, |e| events.push(e));
|
||||
// Either way the function should have returned without panicking.
|
||||
assert!(result.is_ok() || result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_agent_stream_returns_result() {
|
||||
let req = AgentStreamRequest {
|
||||
message: "test".into(),
|
||||
system_prompt: Some("sys".into()),
|
||||
vault_path: "/tmp/nonexistent".into(),
|
||||
};
|
||||
let mut events = vec![];
|
||||
let result = run_agent_stream(req, |e| events.push(e));
|
||||
assert!(result.is_ok() || result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_subprocess_spawn_failure() {
|
||||
let fake_bin = PathBuf::from("/nonexistent/binary/path");
|
||||
let mut events = vec![];
|
||||
let result = run_claude_subprocess(&fake_bin, &[], &mut |e| events.push(e));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Failed to spawn"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_with_tool_progress_and_assistant() {
|
||||
let (result, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s3\"}'\n",
|
||||
"echo '{\"type\":\"tool_progress\",\"tool_name\":\"search\",\"tool_use_id\":\"t1\"}'\n",
|
||||
"echo '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"read\",\"input\":{}}]}}'\n",
|
||||
"echo '{\"type\":\"result\",\"result\":\"fin\",\"session_id\":\"s3\"}'\n",
|
||||
));
|
||||
assert_eq!(result.unwrap(), "s3");
|
||||
assert!(events.len() >= 4);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_subprocess_success_exit_with_session_id_skips_error() {
|
||||
let (_, events) = run_mock_script(concat!(
|
||||
"#!/bin/sh\n",
|
||||
"echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s4\"}'\n",
|
||||
"echo 'some warning' >&2\n",
|
||||
"exit 1\n",
|
||||
));
|
||||
// Should NOT have an error event because session_id is non-empty
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| matches!(e, ClaudeStreamEvent::Error { .. })));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ pub enum FrontmatterValue {
|
||||
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_yaml_special_chars(s: &str) -> bool {
|
||||
s.contains(':') || s.contains('#') || s.contains('\n')
|
||||
s.contains(':') || s.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
@@ -41,6 +41,23 @@ fn format_list_item(item: &str) -> String {
|
||||
format!(" - {}", quote_yaml_string(item))
|
||||
}
|
||||
|
||||
/// Format a multi-line string as a YAML block scalar (`|`).
|
||||
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
|
||||
fn format_block_scalar(s: &str) -> String {
|
||||
let indented = s
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", l)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("|\n{}", indented)
|
||||
}
|
||||
|
||||
/// Format a number for YAML (integers without decimal, floats with).
|
||||
fn format_yaml_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
@@ -54,7 +71,9 @@ impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if needs_yaml_quoting(s) {
|
||||
if s.contains('\n') {
|
||||
format_block_scalar(s)
|
||||
} else if needs_yaml_quoting(s) {
|
||||
quote_yaml_string(s)
|
||||
} else {
|
||||
s.clone()
|
||||
@@ -113,16 +132,20 @@ fn line_is_key(line: &str, key: &str) -> bool {
|
||||
fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
|
||||
let yaml_key = format_yaml_key(key);
|
||||
let yaml_value = value.to_yaml_value();
|
||||
if yaml_value.contains('\n') {
|
||||
if yaml_value.starts_with("|\n") {
|
||||
// Block scalar: key and indicator on the same line, content follows
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
} else if yaml_value.contains('\n') {
|
||||
vec![format!("{}:", yaml_key), yaml_value]
|
||||
} else {
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a line is a YAML list continuation (` - ...`) rather than a new key.
|
||||
fn is_list_continuation(line: &str) -> bool {
|
||||
line.starts_with(" - ") || line.starts_with(" -\t")
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
/// block scalar content, or blank line inside a block scalar).
|
||||
fn is_value_continuation(line: &str) -> bool {
|
||||
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Split content into frontmatter body and the rest after the closing `---`.
|
||||
@@ -163,8 +186,8 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// Skip list continuation lines belonging to this key
|
||||
while i < lines.len() && is_list_continuation(lines[i]) {
|
||||
// Skip continuation lines belonging to this key (lists, block scalars)
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
// Insert replacement value (if any)
|
||||
@@ -699,6 +722,94 @@ mod tests {
|
||||
assert!(updated.contains("title: New Title"));
|
||||
}
|
||||
|
||||
// --- block scalar (multi-line string) tests ---
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_multiline_uses_block_scalar() {
|
||||
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
|
||||
let yaml = v.to_yaml_value();
|
||||
assert!(yaml.starts_with("|\n"));
|
||||
assert!(yaml.contains(" line 1"));
|
||||
assert!(yaml.contains(" line 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_field_block_scalar() {
|
||||
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
|
||||
let lines = format_yaml_field("template", &v);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].starts_with("template: |\n"));
|
||||
assert!(lines[0].contains(" ## Objective"));
|
||||
assert!(lines[0].contains(" ## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_add() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\n## Timeline";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("template: |"));
|
||||
assert!(updated.contains(" ## Objective"));
|
||||
assert!(updated.contains(" ## Timeline"));
|
||||
assert!(updated.contains("type: Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_replace() {
|
||||
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
|
||||
let new_template = "## New\n\n## Content";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(new_template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" ## New"));
|
||||
assert!(updated.contains(" ## Content"));
|
||||
assert!(!updated.contains("## Old"));
|
||||
assert!(!updated.contains("## Stuff"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_block_scalar() {
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(content, "template", None).unwrap();
|
||||
assert!(!updated.contains("template"));
|
||||
assert!(!updated.contains("## Heading"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_block_scalar() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
// Parse back with gray_matter
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let roundtripped = map.get("template").unwrap().as_string().unwrap();
|
||||
assert!(roundtripped.contains("## Objective"));
|
||||
assert!(roundtripped.contains("## Timeline"));
|
||||
assert!(roundtripped.contains("Describe the goal."));
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_body_after_closing() {
|
||||
// Frontmatter with title, no body after closing ---
|
||||
|
||||
@@ -12,6 +12,54 @@ pub struct GitCommit {
|
||||
pub date: i64,
|
||||
}
|
||||
|
||||
/// Initialize a new git repository, stage all files, and create an initial commit.
|
||||
pub fn init_repo(path: &str) -> Result<(), String> {
|
||||
let dir = Path::new(path);
|
||||
|
||||
run_git(dir, &["init"])?;
|
||||
ensure_author_config(dir)?;
|
||||
run_git(dir, &["add", "."])?;
|
||||
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a git command in the given directory, returning an error on failure.
|
||||
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git {}: {}", args[0], e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"git {} failed: {}",
|
||||
args[0],
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
}
|
||||
|
||||
/// Set local user.name and user.email if not already configured.
|
||||
fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
for (key, fallback) in [("user.name", "Laputa"), ("user.email", "vault@laputa.app")] {
|
||||
let check = Command::new("git")
|
||||
.args(["config", key])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check git config: {}", e))?;
|
||||
|
||||
let value = String::from_utf8_lossy(&check.stdout);
|
||||
if !check.status.success() || value.trim().is_empty() {
|
||||
run_git(dir, &["config", key, fallback])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get git log history for a specific file in the vault.
|
||||
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
@@ -80,7 +128,7 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.args(["status", "--porcelain", "--untracked-files=all"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
||||
@@ -379,20 +427,28 @@ pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
|
||||
}
|
||||
|
||||
/// List files with merge conflicts (unmerged paths).
|
||||
///
|
||||
/// Uses `git ls-files --unmerged` instead of `git diff --diff-filter=U` because
|
||||
/// ls-files reliably detects unmerged index entries even when the merge state is
|
||||
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
|
||||
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["diff", "--name-only", "--diff-filter=U"])
|
||||
.args(["ls-files", "--unmerged"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check conflicts: {}", e))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout
|
||||
// Each unmerged file appears multiple times (once per stage: base/ours/theirs).
|
||||
// Format: "<mode> <hash> <stage>\t<path>"
|
||||
let mut files: Vec<String> = stdout
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| l.to_string())
|
||||
.collect())
|
||||
.filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string()))
|
||||
.collect();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Parse `git pull` output to extract updated file paths.
|
||||
@@ -413,6 +469,61 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve a single conflict file by choosing "ours" or "theirs" strategy,
|
||||
/// then stage the result.
|
||||
pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let checkout_flag = match strategy {
|
||||
"ours" => "--ours",
|
||||
"theirs" => "--theirs",
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Invalid strategy '{}': must be 'ours' or 'theirs'",
|
||||
strategy
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
run_git(vault, &["checkout", checkout_flag, "--", file])?;
|
||||
run_git(vault, &["add", "--", file])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit after all merge conflicts have been resolved.
|
||||
pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Verify no remaining conflicts
|
||||
let remaining = get_conflict_files(vault_path)?;
|
||||
if !remaining.is_empty() {
|
||||
return Err(format!(
|
||||
"Cannot commit: {} file(s) still have unresolved conflicts",
|
||||
remaining.len()
|
||||
));
|
||||
}
|
||||
|
||||
let commit = Command::new("git")
|
||||
.args(["commit", "-m", "Resolve merge conflicts"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git commit: {}", e))?;
|
||||
|
||||
if !commit.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&commit.stderr);
|
||||
let stdout = String::from_utf8_lossy(&commit.stdout);
|
||||
let detail = if stderr.trim().is_empty() {
|
||||
stdout
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
return Err(format!("git commit failed: {}", detail.trim()));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
|
||||
}
|
||||
|
||||
/// Push to remote.
|
||||
pub fn git_push(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
@@ -562,6 +673,57 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_creates_git_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(vault.join(".git").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_creates_initial_commit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Initial vault setup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_stages_all_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(vault.join("sub")).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
fs::write(vault.join("sub/nested.md"), "# Nested\n").unwrap();
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let status = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
|
||||
"All files should be committed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_with_commits() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -641,7 +803,42 @@ mod tests {
|
||||
|
||||
assert!(modified.len() >= 2);
|
||||
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
|
||||
assert!(statuses.contains(&"modified") || statuses.contains(&"untracked"));
|
||||
assert!(statuses.contains(&"modified"));
|
||||
assert!(statuses.contains(&"untracked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_untracked_in_subdirectory() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
|
||||
// Create initial commit so git is initialized
|
||||
fs::write(vault.join("init.md"), "# Init\n").unwrap();
|
||||
Command::new("git")
|
||||
.args(["add", "init.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Initial"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Create a new untracked file in a subdirectory (simulates new note creation)
|
||||
fs::create_dir_all(vault.join("note")).unwrap();
|
||||
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
|
||||
|
||||
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(modified.len(), 1);
|
||||
assert_eq!(modified[0].status, "untracked");
|
||||
assert_eq!(modified[0].relative_path, "note/brand-new.md");
|
||||
assert!(
|
||||
modified[0].path.ends_with("/note/brand-new.md"),
|
||||
"Full path should end with relative path: {}",
|
||||
modified[0].path
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1083,6 +1280,113 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Set up a pair of clones that have a merge conflict on the same file.
|
||||
/// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict.
|
||||
fn setup_conflict_pair() -> (TempDir, TempDir, TempDir) {
|
||||
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
|
||||
|
||||
let vp_a = clone_a_dir.path().to_str().unwrap();
|
||||
let vp_b = clone_b_dir.path().to_str().unwrap();
|
||||
|
||||
// A creates the file and pushes
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
|
||||
git_commit(vp_a, "create conflict.md").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
// B pulls to get the file
|
||||
git_pull(vp_b).unwrap();
|
||||
|
||||
// A modifies and pushes
|
||||
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
|
||||
git_commit(vp_a, "A's change").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
// B modifies the same file locally and commits
|
||||
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
|
||||
git_commit(vp_b, "B's change").unwrap();
|
||||
|
||||
// B pulls — this causes a merge conflict
|
||||
let result = git_pull(vp_b).unwrap();
|
||||
assert_eq!(result.status, "conflict");
|
||||
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_ours() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp_b).unwrap();
|
||||
assert!(conflicts.contains(&"conflict.md".to_string()));
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, "# Version B\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_theirs() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, "# Version A\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_invalid_strategy() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let result = git_resolve_conflict(vp_b, "conflict.md", "invalid");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid strategy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
// Resolve all conflicts first
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify the merge commit exists
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(clone_b.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Resolve merge conflicts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_conflict_resolution_fails_with_unresolved() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
// Don't resolve — try to commit directly
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.contains("still have unresolved conflicts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_github_repo_path_non_github() {
|
||||
assert_eq!(
|
||||
|
||||
486
src-tauri/src/indexing.rs
Normal file
@@ -0,0 +1,486 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static QMD_PATH_CACHE: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
/// Locate the qmd binary, checking known locations and PATH.
|
||||
/// Caches the result for subsequent calls.
|
||||
pub fn find_qmd_binary() -> Option<String> {
|
||||
if let Ok(guard) = QMD_PATH_CACHE.lock() {
|
||||
if let Some(ref cached) = *guard {
|
||||
return Some(cached.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let result = find_qmd_binary_uncached();
|
||||
|
||||
if let Some(ref path) = result {
|
||||
if let Ok(mut guard) = QMD_PATH_CACHE.lock() {
|
||||
*guard = Some(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn find_qmd_binary_uncached() -> Option<String> {
|
||||
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(candidate);
|
||||
}
|
||||
}
|
||||
// Fallback: try PATH
|
||||
Command::new("which")
|
||||
.arg("qmd")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear the cached qmd path (e.g. after auto-install).
|
||||
pub fn clear_qmd_cache() {
|
||||
if let Ok(mut guard) = QMD_PATH_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,
|
||||
}
|
||||
|
||||
/// Check whether the vault has a qmd index and its status.
|
||||
pub fn check_index_status(vault_path: &str) -> IndexStatus {
|
||||
let qmd_bin = 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,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
let output = Command::new(&qmd_bin).args(["status"]).output();
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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_bin = find_qmd_binary().ok_or("qmd not installed")?;
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
|
||||
// Check if collection already exists
|
||||
let output = Command::new(&qmd_bin)
|
||||
.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
|
||||
Command::new(&qmd_bin)
|
||||
.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_bin = find_qmd_binary().ok_or("qmd not installed")?;
|
||||
|
||||
ensure_collection(vault_path)?;
|
||||
|
||||
// Phase 1: update (scan files)
|
||||
on_progress(IndexingProgress {
|
||||
phase: "scanning".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
});
|
||||
|
||||
let update_output = Command::new(&qmd_bin)
|
||||
.args(["update"])
|
||||
.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)
|
||||
on_progress(IndexingProgress {
|
||||
phase: "embedding".to_string(),
|
||||
current: 0,
|
||||
total,
|
||||
done: false,
|
||||
error: None,
|
||||
});
|
||||
|
||||
let embed_output = Command::new(&qmd_bin)
|
||||
.args(["embed"])
|
||||
.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}");
|
||||
on_progress(IndexingProgress {
|
||||
phase: "complete".to_string(),
|
||||
current: total,
|
||||
total,
|
||||
done: true,
|
||||
error: Some("Embedding failed — keyword search only".to_string()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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_bin = find_qmd_binary().ok_or("qmd not installed")?;
|
||||
|
||||
// Verify collection exists
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
let list_output = Command::new(&qmd_bin)
|
||||
.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 = Command::new(&qmd_bin)
|
||||
.args(["update"])
|
||||
.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}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempt to auto-install qmd via bun. Returns Ok if successful.
|
||||
pub fn auto_install_qmd() -> Result<String, String> {
|
||||
// Find bun
|
||||
let bun = find_bun().ok_or("bun not installed — cannot auto-install qmd")?;
|
||||
|
||||
let output = Command::new(&bun)
|
||||
.args(["install", "-g", "qmd"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to install qmd: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("qmd installation failed: {stderr}"));
|
||||
}
|
||||
|
||||
// Clear cache so find_qmd_binary() re-discovers
|
||||
clear_qmd_cache();
|
||||
|
||||
match find_qmd_binary() {
|
||||
Some(path) => Ok(path),
|
||||
None => Err("qmd installed but binary not found".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_bun() -> Option<String> {
|
||||
let candidates = [
|
||||
dirs::home_dir().map(|h| h.join(".bun/bin/bun").to_string_lossy().to_string()),
|
||||
Some("/opt/homebrew/bin/bun".to_string()),
|
||||
Some("/usr/local/bin/bun".to_string()),
|
||||
];
|
||||
for candidate in candidates.into_iter().flatten() {
|
||||
if Path::new(&candidate).exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
Command::new("which")
|
||||
.arg("bun")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
pub mod ai_chat;
|
||||
pub mod claude_cli;
|
||||
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_list;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
@@ -14,12 +18,16 @@ use std::process::Child;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent};
|
||||
use frontmatter::FrontmatterValue;
|
||||
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile};
|
||||
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use indexing::{IndexStatus, IndexingProgress};
|
||||
use search::SearchResponse;
|
||||
use settings::Settings;
|
||||
use theme::{ThemeFile, VaultSettings};
|
||||
use vault::{RenameResult, VaultEntry};
|
||||
use vault_list::VaultList;
|
||||
|
||||
/// 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
|
||||
@@ -108,6 +116,21 @@ fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
git::git_commit(&vault_path, &message)
|
||||
}
|
||||
|
||||
fn parse_build_label(version: &str) -> String {
|
||||
let parts: Vec<&str> = version.split('.').collect();
|
||||
match parts.as_slice() {
|
||||
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
|
||||
[_, _, _] => "dev".to_string(),
|
||||
_ => "b?".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_build_number(app_handle: tauri::AppHandle) -> String {
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
parse_build_label(&version)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -120,6 +143,18 @@ fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
git::git_pull(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_resolve_conflict(vault_path: String, file: String, strategy: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_resolve_conflict(&vault_path, &file, &strategy)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_commit_conflict_resolution(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn git_push(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -131,6 +166,41 @@ async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
ai_chat::send_chat(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_claude_cli() -> ClaudeCliStatus {
|
||||
claude_cli::check_cli()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_claude_chat(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: ChatStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stream_claude_agent(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-agent-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -160,6 +230,12 @@ fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_note(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::delete_note(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -230,6 +306,16 @@ fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_vault_list() -> Result<VaultList, String> {
|
||||
vault_list::load_vault_list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_vault_list(list: VaultList) -> Result<(), String> {
|
||||
vault_list::save_vault_list(&list)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
github::github_list_repos(&token).await
|
||||
@@ -279,6 +365,64 @@ async fn search_vault(
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_index_status(vault_path: String) -> IndexStatus {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
indexing::check_index_status(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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 || {
|
||||
// Auto-install qmd if not available
|
||||
if indexing::find_qmd_binary().is_none() {
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "installing".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
match indexing::auto_install_qmd() {
|
||||
Ok(_) => log::info!("qmd auto-installed successfully"),
|
||||
Err(e) => {
|
||||
log::warn!("qmd auto-install failed: {e}");
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "error".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: true,
|
||||
error: Some(format!("qmd not available: {e}")),
|
||||
},
|
||||
);
|
||||
return Err(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]
|
||||
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}"))?
|
||||
}
|
||||
|
||||
struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
|
||||
#[tauri::command]
|
||||
@@ -289,6 +433,48 @@ async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
|
||||
.map_err(|e| format!("Registration task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::list_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
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]
|
||||
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())
|
||||
}
|
||||
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
|
||||
@@ -315,6 +501,11 @@ fn run_startup_tasks() {
|
||||
vault::migrate_is_a_to_type(vp_str),
|
||||
);
|
||||
|
||||
// Seed _themes/ with built-in JSON themes (legacy) if missing
|
||||
theme::seed_default_themes(vp_str);
|
||||
// Seed theme/ with built-in vault theme notes if missing
|
||||
theme::seed_vault_themes(vp_str);
|
||||
|
||||
// Register Laputa MCP server in Claude Code and Cursor configs
|
||||
match mcp::register_mcp(vp_str) {
|
||||
Ok(status) => log::info!("MCP registration: {status}"),
|
||||
@@ -357,6 +548,39 @@ mod tests {
|
||||
let result = expand_tilde("/home/~user/path");
|
||||
assert_eq!(result, "/home/~user/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_release_version() {
|
||||
assert_eq!(parse_build_label("0.20260303.281"), "b281");
|
||||
assert_eq!(parse_build_label("0.20251215.42"), "b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_dev_version() {
|
||||
assert_eq!(parse_build_label("0.1.0"), "dev");
|
||||
assert_eq!(parse_build_label("0.0.0"), "dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_build_label_malformed() {
|
||||
assert_eq!(parse_build_label("invalid"), "b?");
|
||||
assert_eq!(parse_build_label(""), "b?");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_ws_bridge(app: &mut tauri::App) {
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
let vp_str = vault_path.to_string_lossy().to_string();
|
||||
match mcp::spawn_ws_bridge(&vp_str) {
|
||||
Ok(child) => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app.state();
|
||||
*state.0.lock().unwrap() = Some(child);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -371,10 +595,10 @@ pub fn run() {
|
||||
.build(),
|
||||
)?;
|
||||
// Open devtools automatically in debug builds
|
||||
use tauri::Manager;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
window.open_devtools();
|
||||
}
|
||||
// use tauri::Manager;
|
||||
// if let Some(window) = app.get_webview_window("main") {
|
||||
// window.open_devtools();
|
||||
// }
|
||||
}
|
||||
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
@@ -389,23 +613,7 @@ pub fn run() {
|
||||
}
|
||||
|
||||
run_startup_tasks();
|
||||
|
||||
// Spawn the MCP WebSocket bridge for the default vault
|
||||
{
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
let vp_str = vault_path.to_string_lossy().to_string();
|
||||
match mcp::spawn_ws_bridge(&vp_str) {
|
||||
Ok(child) => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app.state();
|
||||
*state.0.lock().unwrap() = Some(child);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
spawn_ws_bridge(app);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -420,19 +628,28 @@ pub fn run() {
|
||||
get_file_diff,
|
||||
get_file_diff_at_commit,
|
||||
git_commit,
|
||||
get_build_number,
|
||||
get_last_commit_info,
|
||||
git_pull,
|
||||
git_push,
|
||||
git_resolve_conflict,
|
||||
git_commit_conflict_resolution,
|
||||
ai_chat,
|
||||
check_claude_cli,
|
||||
stream_claude_chat,
|
||||
stream_claude_agent,
|
||||
save_image,
|
||||
copy_image_to_vault,
|
||||
purge_trash,
|
||||
delete_note,
|
||||
migrate_is_a_to_type,
|
||||
batch_archive_notes,
|
||||
batch_trash_notes,
|
||||
get_settings,
|
||||
update_menu_state,
|
||||
save_settings,
|
||||
load_vault_list,
|
||||
save_vault_list,
|
||||
github_list_repos,
|
||||
github_create_repo,
|
||||
clone_repo,
|
||||
@@ -440,10 +657,20 @@ pub fn run() {
|
||||
github_device_flow_poll,
|
||||
github_get_user,
|
||||
search_vault,
|
||||
get_index_status,
|
||||
start_indexing,
|
||||
trigger_incremental_index,
|
||||
create_getting_started_vault,
|
||||
check_vault_exists,
|
||||
get_default_vault_path,
|
||||
register_mcp_tools
|
||||
register_mcp_tools,
|
||||
list_themes,
|
||||
get_theme,
|
||||
get_vault_settings,
|
||||
save_vault_settings,
|
||||
set_active_theme,
|
||||
create_theme,
|
||||
create_vault_theme
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -27,10 +27,12 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
|
||||
// On macOS the exe lives at Contents/MacOS/<binary>.
|
||||
// Resources are placed at Contents/Resources/ by Tauri.
|
||||
let release_path = exe
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("mcp-server"))
|
||||
.map(|p| p.join("Resources").join("mcp-server"))
|
||||
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
|
||||
if release_path.join("ws-bridge.js").exists() {
|
||||
return Ok(release_path);
|
||||
@@ -294,4 +296,22 @@ mod tests {
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
|
||||
}
|
||||
#[test]
|
||||
fn upsert_returns_error_for_invalid_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
std::fs::write(&config_path, "not valid json{{{{").unwrap();
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
let result = upsert_mcp_config(&config_path, &entry);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_mcp_to_configs_handles_empty_list() {
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
// Empty config list — function should return "registered" (no existing)
|
||||
let status = register_mcp_to_configs(&entry, &[]);
|
||||
// With empty config list, there were no updates, so status should be "registered"
|
||||
assert_eq!(status, "registered");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use tauri::{
|
||||
// Custom menu item IDs that emit events to the frontend.
|
||||
const APP_SETTINGS: &str = "app-settings";
|
||||
const FILE_NEW_NOTE: &str = "file-new-note";
|
||||
const FILE_DAILY_NOTE: &str = "file-daily-note";
|
||||
const FILE_QUICK_OPEN: &str = "file-quick-open";
|
||||
const FILE_SAVE: &str = "file-save";
|
||||
const FILE_CLOSE_TAB: &str = "file-close-tab";
|
||||
@@ -17,13 +18,23 @@ const VIEW_COMMAND_PALETTE: &str = "view-command-palette";
|
||||
const VIEW_ZOOM_IN: &str = "view-zoom-in";
|
||||
const VIEW_ZOOM_OUT: &str = "view-zoom-out";
|
||||
const VIEW_ZOOM_RESET: &str = "view-zoom-reset";
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_TRASH: &str = "note-trash";
|
||||
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
|
||||
const VIEW_GO_BACK: &str = "view-go-back";
|
||||
const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
|
||||
|
||||
const CUSTOM_IDS: &[&str] = &[
|
||||
APP_SETTINGS,
|
||||
FILE_NEW_NOTE,
|
||||
FILE_DAILY_NOTE,
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
FILE_CLOSE_TAB,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
VIEW_EDITOR_ONLY,
|
||||
VIEW_EDITOR_LIST,
|
||||
VIEW_ALL,
|
||||
@@ -32,10 +43,13 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_ZOOM_IN,
|
||||
VIEW_ZOOM_OUT,
|
||||
VIEW_ZOOM_RESET,
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
APP_CHECK_FOR_UPDATES,
|
||||
];
|
||||
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
const NOTE_DEPENDENT_IDS: &[&str] = &[FILE_SAVE, FILE_CLOSE_TAB];
|
||||
const NOTE_DEPENDENT_IDS: &[&str] = &[FILE_SAVE, FILE_CLOSE_TAB, NOTE_ARCHIVE, NOTE_TRASH];
|
||||
|
||||
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
|
||||
|
||||
@@ -44,10 +58,15 @@ fn build_app_menu(app: &App) -> MenuResult {
|
||||
.id(APP_SETTINGS)
|
||||
.accelerator("CmdOrCtrl+,")
|
||||
.build(app)?;
|
||||
let check_updates_item = MenuItemBuilder::new("Check for Updates...")
|
||||
.id(APP_CHECK_FOR_UPDATES)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Laputa")
|
||||
.about(None)
|
||||
.separator()
|
||||
.item(&check_updates_item)
|
||||
.separator()
|
||||
.item(&settings_item)
|
||||
.separator()
|
||||
.services()
|
||||
@@ -65,6 +84,10 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.id(FILE_NEW_NOTE)
|
||||
.accelerator("CmdOrCtrl+N")
|
||||
.build(app)?;
|
||||
let daily_note = MenuItemBuilder::new("Open Today's Note")
|
||||
.id(FILE_DAILY_NOTE)
|
||||
.accelerator("CmdOrCtrl+J")
|
||||
.build(app)?;
|
||||
let quick_open = MenuItemBuilder::new("Quick Open")
|
||||
.id(FILE_QUICK_OPEN)
|
||||
.accelerator("CmdOrCtrl+P")
|
||||
@@ -77,18 +100,35 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.id(FILE_CLOSE_TAB)
|
||||
.accelerator("CmdOrCtrl+W")
|
||||
.build(app)?;
|
||||
let archive_note = MenuItemBuilder::new("Archive Note")
|
||||
.id(NOTE_ARCHIVE)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let trash_note = MenuItemBuilder::new("Trash Note")
|
||||
.id(NOTE_TRASH)
|
||||
.accelerator("CmdOrCtrl+Backspace")
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "File")
|
||||
.item(&new_note)
|
||||
.item(&daily_note)
|
||||
.item(&quick_open)
|
||||
.separator()
|
||||
.item(&save)
|
||||
.separator()
|
||||
.item(&archive_note)
|
||||
.item(&trash_note)
|
||||
.separator()
|
||||
.item(&close_tab)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
fn build_edit_menu(app: &App) -> MenuResult {
|
||||
let find_in_vault = MenuItemBuilder::new("Find in Vault")
|
||||
.id(EDIT_FIND_IN_VAULT)
|
||||
.accelerator("CmdOrCtrl+Shift+F")
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Edit")
|
||||
.undo()
|
||||
.redo()
|
||||
@@ -98,6 +138,8 @@ fn build_edit_menu(app: &App) -> MenuResult {
|
||||
.paste()
|
||||
.separator()
|
||||
.select_all()
|
||||
.separator()
|
||||
.item(&find_in_vault)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
@@ -133,6 +175,14 @@ fn build_view_menu(app: &App) -> MenuResult {
|
||||
.id(VIEW_ZOOM_RESET)
|
||||
.accelerator("CmdOrCtrl+0")
|
||||
.build(app)?;
|
||||
let go_back = MenuItemBuilder::new("Go Back")
|
||||
.id(VIEW_GO_BACK)
|
||||
.accelerator("CmdOrCtrl+[")
|
||||
.build(app)?;
|
||||
let go_forward = MenuItemBuilder::new("Go Forward")
|
||||
.id(VIEW_GO_FORWARD)
|
||||
.accelerator("CmdOrCtrl+]")
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "View")
|
||||
.item(&editor_only)
|
||||
@@ -141,6 +191,9 @@ fn build_view_menu(app: &App) -> MenuResult {
|
||||
.separator()
|
||||
.item(&toggle_inspector)
|
||||
.separator()
|
||||
.item(&go_back)
|
||||
.item(&go_forward)
|
||||
.separator()
|
||||
.item(&zoom_in)
|
||||
.item(&zoom_out)
|
||||
.item(&zoom_reset)
|
||||
@@ -206,9 +259,13 @@ mod tests {
|
||||
let expected = [
|
||||
"app-settings",
|
||||
"file-new-note",
|
||||
"file-daily-note",
|
||||
"file-quick-open",
|
||||
"file-save",
|
||||
"file-close-tab",
|
||||
"note-archive",
|
||||
"note-trash",
|
||||
"edit-find-in-vault",
|
||||
"view-editor-only",
|
||||
"view-editor-list",
|
||||
"view-all",
|
||||
@@ -217,6 +274,9 @@ mod tests {
|
||||
"view-zoom-in",
|
||||
"view-zoom-out",
|
||||
"view-zoom-reset",
|
||||
"view-go-back",
|
||||
"view-go-forward",
|
||||
"app-check-for-updates",
|
||||
];
|
||||
for id in &expected {
|
||||
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::indexing;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -30,31 +31,6 @@ struct QmdResult {
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
fn find_qmd_binary() -> Option<String> {
|
||||
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(candidate);
|
||||
}
|
||||
}
|
||||
// Fallback: try PATH
|
||||
Command::new("which")
|
||||
.arg("qmd")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn qmd_uri_to_vault_path(uri: &str, vault_path: &str) -> String {
|
||||
// qmd://laputa/essay/foo.md → essay/foo.md
|
||||
let relative = uri
|
||||
@@ -108,7 +84,7 @@ fn detect_collection_name(vault_path: &str) -> String {
|
||||
}
|
||||
|
||||
fn detect_collection_name_uncached(vault_path: &str) -> String {
|
||||
let qmd_bin = match find_qmd_binary() {
|
||||
let qmd_bin = match indexing::find_qmd_binary() {
|
||||
Some(b) => b,
|
||||
None => return "laputa".to_string(),
|
||||
};
|
||||
@@ -149,8 +125,8 @@ pub fn search_vault(
|
||||
) -> Result<SearchResponse, String> {
|
||||
let start = Instant::now();
|
||||
|
||||
let qmd_bin =
|
||||
find_qmd_binary().ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?;
|
||||
let qmd_bin = indexing::find_qmd_binary()
|
||||
.ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?;
|
||||
|
||||
let collection = detect_collection_name(vault_path);
|
||||
|
||||
@@ -255,4 +231,17 @@ mod tests {
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,36 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
save_settings_at(&settings_path()?, settings)
|
||||
}
|
||||
|
||||
fn last_vault_file() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("last-vault.txt"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn get_last_vault_at(path: &PathBuf) -> Option<String> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn set_last_vault_at(path: &PathBuf, vault_path: &str) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
fs::write(path, vault_path.trim())
|
||||
.map_err(|e| format!("Failed to write last vault path: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_last_vault() -> Option<String> {
|
||||
last_vault_file().ok().and_then(|p| get_last_vault_at(&p))
|
||||
}
|
||||
|
||||
pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
|
||||
set_last_vault_at(&last_vault_file()?, vault_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -199,4 +229,65 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_vault_returns_none_for_missing_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
assert!(get_last_vault_at(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_and_get_last_vault_roundtrip() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/MyVault").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/MyVault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_trims_whitespace() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, " /Users/test/Vault ").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/Vault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_vault_returns_none_for_empty_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
fs::write(&path, " \n ").unwrap();
|
||||
assert!(get_last_vault_at(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_creates_parent_directories() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("dir").join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/Vault").unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/Vault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_overwrites_previous() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/OldVault").unwrap();
|
||||
set_last_vault_at(&path, "/Users/test/NewVault").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/NewVault")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
859
src-tauri/src/theme.rs
Normal file
@@ -0,0 +1,859 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// 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.
|
||||
/// Seeds built-in themes if the directory is missing.
|
||||
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() {
|
||||
seed_default_themes(vault_path);
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/// Create `dir` and write each `(filename, content)` pair if the directory doesn't exist yet.
|
||||
fn seed_dir_with_files(dir: &Path, files: &[(&str, &str)], log_msg: &str) {
|
||||
if dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
if fs::create_dir_all(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
for (name, content) in files {
|
||||
let _ = fs::write(dir.join(name), content);
|
||||
}
|
||||
log::info!("{log_msg}");
|
||||
}
|
||||
|
||||
/// Seed the `_themes/` directory with built-in themes if it doesn't exist yet.
|
||||
/// Safe to call multiple times — only writes files that are missing.
|
||||
pub fn seed_default_themes(vault_path: &str) {
|
||||
seed_dir_with_files(
|
||||
&Path::new(vault_path).join("_themes"),
|
||||
&[
|
||||
("default.json", DEFAULT_THEME),
|
||||
("dark.json", DARK_THEME),
|
||||
("minimal.json", MINIMAL_THEME),
|
||||
],
|
||||
"Seeded _themes/ with built-in themes",
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
|
||||
/// Safe to call multiple times — only writes files that are missing.
|
||||
pub fn seed_vault_themes(vault_path: &str) {
|
||||
seed_dir_with_files(
|
||||
&Path::new(vault_path).join("theme"),
|
||||
&[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
],
|
||||
"Seeded theme/ with built-in vault themes",
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a new vault theme note in `theme/` directory.
|
||||
/// 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 theme_dir = Path::new(vault_path).join("theme");
|
||||
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
|
||||
let display_name = name.unwrap_or("Untitled Theme");
|
||||
let slug = slugify(display_name);
|
||||
let filename = format!("{}.md", find_available_stem(&theme_dir, &slug, "md"));
|
||||
let path = theme_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())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// Values with '#' or spaces need quoting; others can be bare strings.
|
||||
if 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
|
||||
}
|
||||
|
||||
/// Create a new theme file by copying the active theme (or default).
|
||||
/// Returns the ID of the new theme.
|
||||
pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String, String> {
|
||||
let themes_dir = Path::new(vault_path).join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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"
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// CSS variable key-value pairs for the default light vault theme.
|
||||
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
// shadcn/ui base
|
||||
("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-muted", "#B4B4B4"),
|
||||
("text-heading", "#37352F"),
|
||||
// Backgrounds
|
||||
("bg-primary", "#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
|
||||
(
|
||||
"font-family",
|
||||
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
),
|
||||
("font-size-base", "14px"),
|
||||
// Editor
|
||||
("editor-font-size", "16"),
|
||||
("editor-line-height", "1.5"),
|
||||
("editor-max-width", "720"),
|
||||
];
|
||||
|
||||
/// Vault-based theme note for the built-in Default theme.
|
||||
pub const DEFAULT_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: Light theme with warm, paper-like tones\n\
|
||||
background: \"#FFFFFF\"\n\
|
||||
foreground: \"#37352F\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#EBEBEA\"\n\
|
||||
secondary-foreground: \"#37352F\"\n\
|
||||
muted: \"#F0F0EF\"\n\
|
||||
muted-foreground: \"#787774\"\n\
|
||||
accent: \"#EBEBEA\"\n\
|
||||
accent-foreground: \"#37352F\"\n\
|
||||
destructive: \"#E03E3E\"\n\
|
||||
border: \"#E9E9E7\"\n\
|
||||
input: \"#E9E9E7\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#F7F6F3\"\n\
|
||||
sidebar-foreground: \"#37352F\"\n\
|
||||
sidebar-border: \"#E9E9E7\"\n\
|
||||
sidebar-accent: \"#EBEBEA\"\n\
|
||||
text-primary: \"#37352F\"\n\
|
||||
text-secondary: \"#787774\"\n\
|
||||
text-muted: \"#B4B4B4\"\n\
|
||||
text-heading: \"#37352F\"\n\
|
||||
bg-primary: \"#FFFFFF\"\n\
|
||||
bg-sidebar: \"#F7F6F3\"\n\
|
||||
bg-hover: \"#EBEBEA\"\n\
|
||||
bg-hover-subtle: \"#F0F0EF\"\n\
|
||||
bg-selected: \"#E8F4FE\"\n\
|
||||
border-primary: \"#E9E9E7\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#E03E3E\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF14\"\n\
|
||||
accent-green-light: \"#00B38B14\"\n\
|
||||
accent-purple-light: \"#A932FF14\"\n\
|
||||
accent-red-light: \"#E03E3E14\"\n\
|
||||
accent-yellow-light: \"#F0B10014\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Default Theme\n\
|
||||
\n\
|
||||
The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
|
||||
|
||||
/// Vault-based theme note for the built-in Dark theme.
|
||||
pub const DARK_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: Dark variant with deep navy tones\n\
|
||||
background: \"#0f0f1a\"\n\
|
||||
foreground: \"#e0e0e0\"\n\
|
||||
card: \"#16162a\"\n\
|
||||
popover: \"#1e1e3a\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#2a2a4a\"\n\
|
||||
secondary-foreground: \"#e0e0e0\"\n\
|
||||
muted: \"#1e1e3a\"\n\
|
||||
muted-foreground: \"#888888\"\n\
|
||||
accent: \"#2a2a4a\"\n\
|
||||
accent-foreground: \"#e0e0e0\"\n\
|
||||
destructive: \"#f44336\"\n\
|
||||
border: \"#2a2a4a\"\n\
|
||||
input: \"#2a2a4a\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#1a1a2e\"\n\
|
||||
sidebar-foreground: \"#e0e0e0\"\n\
|
||||
sidebar-border: \"#2a2a4a\"\n\
|
||||
sidebar-accent: \"#2a2a4a\"\n\
|
||||
text-primary: \"#e0e0e0\"\n\
|
||||
text-secondary: \"#888888\"\n\
|
||||
text-muted: \"#666666\"\n\
|
||||
text-heading: \"#e0e0e0\"\n\
|
||||
bg-primary: \"#0f0f1a\"\n\
|
||||
bg-sidebar: \"#1a1a2e\"\n\
|
||||
bg-hover: \"#2a2a4a\"\n\
|
||||
bg-hover-subtle: \"#1e1e3a\"\n\
|
||||
bg-selected: \"#155DFF22\"\n\
|
||||
border-primary: \"#2a2a4a\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#f44336\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF33\"\n\
|
||||
accent-green-light: \"#00B38B33\"\n\
|
||||
accent-purple-light: \"#A932FF33\"\n\
|
||||
accent-red-light: \"#f4433633\"\n\
|
||||
accent-yellow-light: \"#F0B10033\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Dark Theme\n\
|
||||
\n\
|
||||
A dark theme with deep navy tones for comfortable night-time reading.\n";
|
||||
|
||||
/// Vault-based theme note for the built-in Minimal theme.
|
||||
pub const MINIMAL_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: High contrast, minimal chrome\n\
|
||||
background: \"#FAFAFA\"\n\
|
||||
foreground: \"#111111\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#000000\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#F0F0F0\"\n\
|
||||
secondary-foreground: \"#111111\"\n\
|
||||
muted: \"#F5F5F5\"\n\
|
||||
muted-foreground: \"#666666\"\n\
|
||||
accent: \"#F0F0F0\"\n\
|
||||
accent-foreground: \"#111111\"\n\
|
||||
destructive: \"#CC0000\"\n\
|
||||
border: \"#E0E0E0\"\n\
|
||||
input: \"#E0E0E0\"\n\
|
||||
ring: \"#000000\"\n\
|
||||
sidebar: \"#F5F5F5\"\n\
|
||||
sidebar-foreground: \"#111111\"\n\
|
||||
sidebar-border: \"#E0E0E0\"\n\
|
||||
sidebar-accent: \"#E8E8E8\"\n\
|
||||
text-primary: \"#111111\"\n\
|
||||
text-secondary: \"#666666\"\n\
|
||||
text-muted: \"#999999\"\n\
|
||||
text-heading: \"#111111\"\n\
|
||||
bg-primary: \"#FAFAFA\"\n\
|
||||
bg-sidebar: \"#F5F5F5\"\n\
|
||||
bg-hover: \"#EBEBEB\"\n\
|
||||
bg-hover-subtle: \"#F5F5F5\"\n\
|
||||
bg-selected: \"#00000014\"\n\
|
||||
border-primary: \"#E0E0E0\"\n\
|
||||
accent-blue: \"#000000\"\n\
|
||||
accent-green: \"#006600\"\n\
|
||||
accent-orange: \"#996600\"\n\
|
||||
accent-red: \"#CC0000\"\n\
|
||||
accent-purple: \"#660099\"\n\
|
||||
accent-yellow: \"#996600\"\n\
|
||||
accent-blue-light: \"#00000014\"\n\
|
||||
accent-green-light: \"#00660014\"\n\
|
||||
accent-purple-light: \"#66009914\"\n\
|
||||
accent-red-light: \"#CC000014\"\n\
|
||||
accent-yellow-light: \"#99660014\"\n\
|
||||
font-family: \"'SF Mono', 'Menlo', monospace\"\n\
|
||||
font-size-base: 13px\n\
|
||||
editor-font-size: 15\n\
|
||||
editor-line-height: 1.6\n\
|
||||
editor-max-width: 680\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Minimal Theme\n\
|
||||
\n\
|
||||
High contrast, minimal chrome. Monospace typography throughout.\n";
|
||||
|
||||
#[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_seeds_defaults_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_eq!(themes.len(), 3);
|
||||
let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"Default"));
|
||||
assert!(names.contains(&"Dark"));
|
||||
assert!(names.contains(&"Minimal"));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
// Default settings have no theme
|
||||
let settings = get_vault_settings(vp).unwrap();
|
||||
assert!(settings.theme.is_none());
|
||||
|
||||
// Set and read back
|
||||
set_active_theme(vp, Some("dark")).unwrap();
|
||||
let settings = get_vault_settings(vp).unwrap();
|
||||
assert_eq!(settings.theme.as_deref(), Some("dark"));
|
||||
|
||||
// Clear theme
|
||||
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_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_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); // only default and dark
|
||||
}
|
||||
|
||||
#[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); // broken.json is skipped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_vault_themes_creates_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();
|
||||
|
||||
assert!(!vault.join("theme").exists());
|
||||
seed_vault_themes(vp);
|
||||
assert!(vault.join("theme").is_dir());
|
||||
assert!(vault.join("theme").join("default.md").exists());
|
||||
assert!(vault.join("theme").join("dark.md").exists());
|
||||
assert!(vault.join("theme").join("minimal.md").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("theme").join("default.md").exists());
|
||||
}
|
||||
|
||||
#[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_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:"));
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,16 @@ use super::{parse_md_file, scan_vault, VaultEntry};
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 2;
|
||||
const CACHE_VERSION: u32 = 4;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
#[serde(default = "default_cache_version")]
|
||||
version: u32,
|
||||
/// The vault path when the cache was written. Used to detect stale caches
|
||||
/// from a different machine or a moved vault directory.
|
||||
#[serde(default)]
|
||||
vault_path: String,
|
||||
commit_hash: String,
|
||||
entries: Vec<VaultEntry>,
|
||||
}
|
||||
@@ -50,11 +54,6 @@ fn parse_porcelain_line(line: &str) -> Option<(&str, String)> {
|
||||
Some((&line[..2], line[3..].trim().to_string()))
|
||||
}
|
||||
|
||||
/// Check if a porcelain status indicates a new/untracked file.
|
||||
fn is_new_file_status(status: &str) -> bool {
|
||||
status == "??" || status.starts_with('A')
|
||||
}
|
||||
|
||||
/// Extract .md file paths from git diff --name-only output.
|
||||
fn collect_md_paths_from_diff(stdout: &str) -> Vec<String> {
|
||||
stdout
|
||||
@@ -80,11 +79,15 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
|
||||
.map(|s| collect_md_paths_from_diff(&s))
|
||||
.unwrap_or_default();
|
||||
|
||||
let uncommitted = run_git(vault, &["status", "--porcelain"])
|
||||
// Use ls-files for untracked files so that newly-seeded directories are picked up
|
||||
// as individual files rather than as a single "?? dirname/" entry.
|
||||
let uncommitted = git_uncommitted_files(vault);
|
||||
// Also include modified-but-unstaged files via status --porcelain.
|
||||
let modified = run_git(vault, &["status", "--porcelain"])
|
||||
.map(|s| collect_md_paths_from_porcelain(&s))
|
||||
.unwrap_or_default();
|
||||
|
||||
for path in uncommitted {
|
||||
for path in uncommitted.into_iter().chain(modified) {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
@@ -93,17 +96,10 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
|
||||
files
|
||||
}
|
||||
|
||||
fn git_uncommitted_new_files(vault: &Path) -> Vec<String> {
|
||||
let stdout = match run_git(vault, &["status", "--porcelain"]) {
|
||||
Some(s) => s,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(parse_porcelain_line)
|
||||
.filter(|(status, path)| path.ends_with(".md") && is_new_file_status(status))
|
||||
.map(|(_, path)| path)
|
||||
.collect()
|
||||
fn git_uncommitted_files(vault: &Path) -> Vec<String> {
|
||||
run_git(vault, &["status", "--porcelain"])
|
||||
.map(|s| collect_md_paths_from_porcelain(&s))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn load_cache(vault: &Path) -> Option<VaultCache> {
|
||||
@@ -115,6 +111,7 @@ fn write_cache(vault: &Path, cache: &VaultCache) {
|
||||
if let Ok(data) = serde_json::to_string(cache) {
|
||||
let _ = fs::write(cache_path(vault), data);
|
||||
}
|
||||
ensure_cache_excluded(vault);
|
||||
}
|
||||
|
||||
/// Normalize an absolute path to a relative path for comparison with git output.
|
||||
@@ -143,6 +140,23 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ensure `.laputa-cache.json` is excluded from git via `.git/info/exclude`.
|
||||
/// This prevents the cache (which contains machine-specific absolute paths)
|
||||
/// from being committed and causing stale-path bugs on cloned vaults.
|
||||
fn ensure_cache_excluded(vault: &Path) {
|
||||
let exclude_path = vault.join(".git/info/exclude");
|
||||
let entry = ".laputa-cache.json";
|
||||
if let Ok(content) = fs::read_to_string(&exclude_path) {
|
||||
if content.lines().any(|line| line.trim() == entry) {
|
||||
return;
|
||||
}
|
||||
let separator = if content.ends_with('\n') { "" } else { "\n" };
|
||||
let _ = fs::write(&exclude_path, format!("{content}{separator}{entry}\n"));
|
||||
} else if exclude_path.parent().map(|p| p.is_dir()).unwrap_or(false) {
|
||||
let _ = fs::write(&exclude_path, format!("{entry}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort entries by modified_at descending and write the cache.
|
||||
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
|
||||
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
@@ -150,6 +164,7 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
vault,
|
||||
&VaultCache {
|
||||
version: CACHE_VERSION,
|
||||
vault_path: vault.to_string_lossy().to_string(),
|
||||
commit_hash: hash,
|
||||
entries: entries.clone(),
|
||||
},
|
||||
@@ -157,23 +172,19 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
entries
|
||||
}
|
||||
|
||||
/// Handle same-commit cache hit: add any uncommitted new files.
|
||||
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
|
||||
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
||||
let new_files = git_uncommitted_new_files(vault);
|
||||
let mut entries = cache.entries;
|
||||
let existing: std::collections::HashSet<String> = entries
|
||||
.iter()
|
||||
.map(|e| to_relative_path(&e.path, vault))
|
||||
.collect();
|
||||
|
||||
let new_entries = parse_files_at(vault, &new_files);
|
||||
for entry in new_entries {
|
||||
let rel = to_relative_path(&entry.path, vault);
|
||||
if !existing.contains(&rel) {
|
||||
entries.push(entry);
|
||||
}
|
||||
let changed = git_uncommitted_files(vault);
|
||||
if changed.is_empty() {
|
||||
return cache.entries;
|
||||
}
|
||||
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
let mut entries: Vec<VaultEntry> = cache
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
|
||||
.collect();
|
||||
entries.extend(parse_files_at(vault, &changed));
|
||||
finalize_and_cache(vault, entries, cache.commit_hash)
|
||||
}
|
||||
|
||||
@@ -212,7 +223,10 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
};
|
||||
|
||||
if let Some(cache) = load_cache(vault_path) {
|
||||
if cache.version != CACHE_VERSION {
|
||||
let current_vault_str = vault_path.to_string_lossy();
|
||||
let cache_stale = cache.version != CACHE_VERSION
|
||||
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref());
|
||||
if cache_stale {
|
||||
let entries = scan_vault(vault_path)?;
|
||||
return Ok(finalize_and_cache(vault_path, entries, current_hash));
|
||||
}
|
||||
@@ -300,6 +314,71 @@ mod tests {
|
||||
assert_eq!(entries2[0].title, "Note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_invalidates_stale_vault_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init git repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "note.md", "# Note\n\nContent.");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Build cache normally
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0]
|
||||
.path
|
||||
.starts_with(&vault.to_string_lossy().as_ref()),
|
||||
"Entry path should start with vault path"
|
||||
);
|
||||
|
||||
// Tamper with cache to simulate a clone from a different machine
|
||||
let cache_file = cache_path(vault);
|
||||
let cache_data = fs::read_to_string(&cache_file).unwrap();
|
||||
let tampered = cache_data.replace(
|
||||
&vault.to_string_lossy().as_ref(),
|
||||
"/Users/other-machine/OtherVault",
|
||||
);
|
||||
fs::write(&cache_file, tampered).unwrap();
|
||||
|
||||
// Rescanning should invalidate the stale cache and produce correct paths
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert!(
|
||||
entries2[0]
|
||||
.path
|
||||
.starts_with(&vault.to_string_lossy().as_ref()),
|
||||
"After stale-cache invalidation, paths should use the current vault path, got: {}",
|
||||
entries2[0].path
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_incremental_different_commit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -358,4 +437,108 @@ mod tests {
|
||||
assert!(titles.contains(&"First"));
|
||||
assert!(titles.contains(&"Second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_picks_up_modified_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Commit a type note without sidebar label
|
||||
create_test_file(vault, "type/news.md", "---\ntype: Type\n---\n# News\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Prime the cache (same commit hash)
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].sidebar_label, None);
|
||||
|
||||
// User edits the type note to add sidebar label (uncommitted)
|
||||
create_test_file(
|
||||
vault,
|
||||
"type/news.md",
|
||||
"---\ntype: Type\nsidebar label: News\n---\n# News\n",
|
||||
);
|
||||
|
||||
// Reload with same git HEAD — must pick up the modification
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert_eq!(
|
||||
entries2[0].sidebar_label,
|
||||
Some("News".to_string()),
|
||||
"sidebarLabel must reflect the uncommitted edit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_new_file_still_added() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "existing.md", "# Existing\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Prime cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
|
||||
// Create new untracked file
|
||||
create_test_file(vault, "new-note.md", "# New Note\n");
|
||||
|
||||
// Cache still same commit — new untracked file must appear
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 2);
|
||||
let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect();
|
||||
assert!(titles.contains(&"Existing"));
|
||||
assert!(titles.contains(&"New Note"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
/// Default location for the Getting Started vault.
|
||||
pub fn default_vault_path() -> Result<PathBuf, String> {
|
||||
dirs::document_dir()
|
||||
.map(|d| d.join("Laputa"))
|
||||
.map(|d| d.join("Getting Started"))
|
||||
.ok_or_else(|| "Could not determine Documents directory".to_string())
|
||||
}
|
||||
|
||||
@@ -18,6 +18,104 @@ struct SampleFile {
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Content for the AGENTS.md file written to the vault root.
|
||||
/// This file has no YAML frontmatter — it is a convention file for AI agents,
|
||||
/// not a vault note. The vault scanner will still pick it up as a regular entry.
|
||||
const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
|
||||
|
||||
This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph.
|
||||
|
||||
## Structure
|
||||
|
||||
Files are organized in folders by type:
|
||||
|
||||
| Folder | Type | Purpose |
|
||||
|--------|------|---------|
|
||||
| `note/` | Note | General-purpose documents, research, meeting notes |
|
||||
| `project/` | Project | Time-bounded efforts with clear goals |
|
||||
| `person/` | Person | People — colleagues, collaborators, contacts |
|
||||
| `topic/` | Topic | Subject areas that group related notes |
|
||||
| `responsibility/` | Responsibility | Long-running duties with KPIs |
|
||||
| `procedure/` | Procedure | Recurring workflows (weekly, monthly) |
|
||||
| `event/` | Event | Something that happened on a specific date |
|
||||
| `quarter/` | Quarter | Time containers (e.g. 24Q1) |
|
||||
| `measure/` | Measure | Trackable metrics tied to responsibilities |
|
||||
| `target/` | Target | Time-bound goals for a measure |
|
||||
| `type/` | Type | Type definitions — icon, color, ordering |
|
||||
|
||||
Custom folders are valid — the folder name becomes the type (capitalized).
|
||||
|
||||
## Frontmatter
|
||||
|
||||
YAML frontmatter between `---` delimiters defines metadata:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Owner: "[[person/jane-doe]]"
|
||||
Belongs to: "[[quarter/24q1]]"
|
||||
Related to:
|
||||
- "[[topic/growth]]"
|
||||
- "[[note/research-findings]]"
|
||||
---
|
||||
```
|
||||
|
||||
### Standard fields
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `Is A` | Entity type (usually inferred from folder) |
|
||||
| `Status` | Active, Done, Paused, Archived, Dropped |
|
||||
| `Owner` | Person responsible (wikilink) |
|
||||
| `Belongs to` | Parent relationship(s) |
|
||||
| `Related to` | Lateral associations |
|
||||
| `Cadence` | For Procedures: Weekly, Monthly, etc. |
|
||||
| `aliases` | Alternative names for wikilink resolution |
|
||||
|
||||
### Custom fields
|
||||
|
||||
Any YAML field containing `[[wikilinks]]` becomes a navigable relationship:
|
||||
|
||||
```yaml
|
||||
Has Measures: ["[[measure/revenue]]", "[[measure/churn]]"]
|
||||
Resources: "[[note/api-docs]]"
|
||||
```
|
||||
|
||||
## Wikilinks
|
||||
|
||||
Connect notes with double-bracket syntax:
|
||||
|
||||
- `[[note/my-note]]` — link by path
|
||||
- `[[My Note Title]]` — link by title or alias
|
||||
- `[[note/my-note|display text]]` — link with custom display text
|
||||
|
||||
Wikilinks work in both frontmatter values and markdown body. Backlinks are computed automatically — linking A to B makes B show a backlink to A.
|
||||
|
||||
## Type definitions
|
||||
|
||||
Files in `type/` define entity types and control how they appear in the sidebar:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Type
|
||||
icon: rocket-launch
|
||||
color: purple
|
||||
order: 1
|
||||
---
|
||||
```
|
||||
|
||||
Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor names in kebab-case.
|
||||
|
||||
## Conventions
|
||||
|
||||
- First `# Heading` in a file becomes its title
|
||||
- One entity per file
|
||||
- Filenames use kebab-case: `my-note-title.md`
|
||||
- Type is inferred from parent folder if not set in frontmatter
|
||||
- Relationships are bidirectional via automatic backlinks
|
||||
"#;
|
||||
|
||||
const SAMPLE_FILES: &[SampleFile] = &[
|
||||
SampleFile {
|
||||
rel_path: "type/project.md",
|
||||
@@ -282,6 +380,10 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
fs::create_dir_all(vault_dir)
|
||||
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
|
||||
|
||||
// Write AGENTS.md at the vault root
|
||||
fs::write(vault_dir.join("AGENTS.md"), AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {}", e))?;
|
||||
|
||||
for sample in SAMPLE_FILES {
|
||||
let file_path = vault_dir.join(sample.rel_path);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
@@ -292,6 +394,19 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
|
||||
}
|
||||
|
||||
// Seed built-in themes
|
||||
let themes_dir = vault_dir.join("_themes");
|
||||
fs::create_dir_all(&themes_dir)
|
||||
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
|
||||
fs::write(themes_dir.join("default.json"), crate::theme::DEFAULT_THEME)
|
||||
.map_err(|e| format!("Failed to write default theme: {e}"))?;
|
||||
fs::write(themes_dir.join("dark.json"), crate::theme::DARK_THEME)
|
||||
.map_err(|e| format!("Failed to write dark theme: {e}"))?;
|
||||
fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME)
|
||||
.map_err(|e| format!("Failed to write minimal theme: {e}"))?;
|
||||
|
||||
crate::git::init_repo(target_path)?;
|
||||
|
||||
Ok(vault_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| vault_dir.to_path_buf())
|
||||
@@ -308,7 +423,7 @@ mod tests {
|
||||
let path = default_vault_path().unwrap();
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(path_str.contains("Documents"));
|
||||
assert!(path_str.ends_with("Laputa"));
|
||||
assert!(path_str.ends_with("Getting Started"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -324,6 +439,7 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify key files exist
|
||||
assert!(vault_path.join("AGENTS.md").exists());
|
||||
assert!(vault_path.join("note/welcome-to-laputa.md").exists());
|
||||
assert!(vault_path.join("note/editor-basics.md").exists());
|
||||
assert!(vault_path.join("note/using-properties.md").exists());
|
||||
@@ -391,6 +507,104 @@ mod tests {
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entries = crate::vault::scan_vault(&vault_path).unwrap();
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len());
|
||||
// SAMPLE_FILES + AGENTS.md
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_present_after_vault_creation() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let agents_path = vault_path.join("AGENTS.md");
|
||||
assert!(agents_path.exists(), "AGENTS.md should exist at vault root");
|
||||
|
||||
let content = fs::read_to_string(&agents_path).unwrap();
|
||||
assert!(
|
||||
content.contains("Vault Instructions for AI Agents"),
|
||||
"AGENTS.md should contain instructions header"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Structure"),
|
||||
"AGENTS.md should describe vault structure"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Frontmatter"),
|
||||
"AGENTS.md should describe frontmatter"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Wikilinks"),
|
||||
"AGENTS.md should describe wikilinks"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Type definitions"),
|
||||
"AGENTS.md should describe type definitions"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Conventions"),
|
||||
"AGENTS.md should describe conventions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_parseable_as_vault_entry() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
|
||||
assert_eq!(
|
||||
entry.title,
|
||||
"AGENTS.md \u{2014} Vault Instructions for AI Agents"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_initializes_git() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("git-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(vault_path.join(".git").exists());
|
||||
|
||||
let log = std::process::Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
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();
|
||||
|
||||
assert!(vault_path.join("_themes/default.json").exists());
|
||||
assert!(vault_path.join("_themes/dark.json").exists());
|
||||
assert!(vault_path.join("_themes/minimal.json").exists());
|
||||
|
||||
let themes = crate::theme::list_themes(vault_path.to_str().unwrap()).unwrap();
|
||||
assert_eq!(themes.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_no_untracked_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("clean-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let status = std::process::Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
|
||||
"All files should be committed, no untracked files"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{rename_note, RenameResult};
|
||||
pub use trash::purge_trash;
|
||||
pub use trash::{delete_note, purge_trash};
|
||||
|
||||
use parsing::{
|
||||
capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet,
|
||||
@@ -62,6 +62,15 @@ pub struct VaultEntry {
|
||||
pub color: Option<String>,
|
||||
/// Display order for Type entries in sidebar (lower = higher). None = use default order.
|
||||
pub order: Option<i64>,
|
||||
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
|
||||
#[serde(rename = "sidebarLabel")]
|
||||
pub sidebar_label: Option<String>,
|
||||
/// Markdown template for notes of this Type. When a new note is created
|
||||
/// with this type, the template body is pre-filled after the frontmatter.
|
||||
pub template: Option<String>,
|
||||
/// Default sort preference for the note list when viewing instances of this Type.
|
||||
/// Stored as "option:direction" (e.g. "modified:desc", "title:asc", "property:Priority:asc").
|
||||
pub sort: Option<String>,
|
||||
/// Word count of the note body (excludes frontmatter and H1 title).
|
||||
#[serde(rename = "wordCount")]
|
||||
pub word_count: u32,
|
||||
@@ -69,6 +78,10 @@ pub struct VaultEntry {
|
||||
/// Extracted from `[[target]]` and `[[target|display]]` patterns.
|
||||
#[serde(rename = "outgoingLinks", default)]
|
||||
pub outgoing_links: Vec<String>,
|
||||
/// Custom scalar frontmatter properties (non-relationship, non-structural).
|
||||
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Intermediate struct to capture YAML frontmatter fields.
|
||||
@@ -104,6 +117,12 @@ struct Frontmatter {
|
||||
color: Option<String>,
|
||||
#[serde(default)]
|
||||
order: Option<i64>,
|
||||
#[serde(rename = "sidebar label", default)]
|
||||
sidebar_label: Option<String>,
|
||||
#[serde(default)]
|
||||
template: Option<String>,
|
||||
#[serde(default)]
|
||||
sort: Option<String>,
|
||||
}
|
||||
|
||||
/// Handles YAML fields that can be either a single string or a list of strings.
|
||||
@@ -147,6 +166,9 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
"sidebar label",
|
||||
"template",
|
||||
"sort",
|
||||
];
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
@@ -188,6 +210,45 @@ fn extract_relationships(
|
||||
relationships
|
||||
}
|
||||
|
||||
/// Additional keys to skip when extracting custom properties.
|
||||
/// These are already first-class fields on VaultEntry, so including them
|
||||
/// in `properties` would duplicate information.
|
||||
const PROPERTY_EXTRA_SKIP: &[&str] = &["belongs to", "related to", "owner"];
|
||||
|
||||
/// Extract custom scalar properties from raw YAML frontmatter.
|
||||
/// Captures string, number, and boolean values that are not structural fields
|
||||
/// and do not contain wikilinks. Arrays and objects are excluded.
|
||||
fn extract_properties(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
let mut properties = HashMap::new();
|
||||
|
||||
for (key, value) in data {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
if SKIP_KEYS.iter().any(|k| k.eq_ignore_ascii_case(&lower))
|
||||
|| PROPERTY_EXTRA_SKIP
|
||||
.iter()
|
||||
.any(|k| k.eq_ignore_ascii_case(&lower))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::String(s) => {
|
||||
if !contains_wikilink(s) {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
properties
|
||||
}
|
||||
|
||||
/// Infer entity type from a parent folder name.
|
||||
fn infer_type_from_folder(folder: &str) -> String {
|
||||
match folder {
|
||||
@@ -231,19 +292,24 @@ fn parse_created_at(fm: &Frontmatter) -> Option<u64> {
|
||||
.or_else(|| fm.created_time.as_ref().and_then(|s| parse_iso_date(s)))
|
||||
}
|
||||
|
||||
/// Extract frontmatter and relationships from parsed gray_matter data.
|
||||
/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data.
|
||||
fn extract_fm_and_rels(
|
||||
data: Option<gray_matter::Pod>,
|
||||
) -> (Frontmatter, HashMap<String, Vec<String>>) {
|
||||
) -> (
|
||||
Frontmatter,
|
||||
HashMap<String, Vec<String>>,
|
||||
HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
let hash = match data {
|
||||
Some(gray_matter::Pod::Hash(map)) => map,
|
||||
_ => return (Frontmatter::default(), HashMap::new()),
|
||||
_ => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
|
||||
};
|
||||
let json_map: HashMap<String, serde_json::Value> =
|
||||
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
|
||||
(
|
||||
parse_frontmatter(&json_map),
|
||||
extract_relationships(&json_map),
|
||||
extract_properties(&json_map),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,7 +336,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&content);
|
||||
let (frontmatter, mut relationships) = extract_fm_and_rels(parsed.data);
|
||||
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data);
|
||||
|
||||
let title = extract_title(&parsed.content, &filename);
|
||||
let snippet = extract_snippet(&content);
|
||||
@@ -325,8 +391,12 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
icon: frontmatter.icon,
|
||||
color: frontmatter.color,
|
||||
order: frontmatter.order,
|
||||
sidebar_label: frontmatter.sidebar_label,
|
||||
template: frontmatter.template,
|
||||
sort: frontmatter.sort,
|
||||
word_count,
|
||||
outgoing_links,
|
||||
properties,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1055,6 +1125,193 @@ References:
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
// --- sidebar_label tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidebar_label_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsidebar label: News\n---\n# News\n";
|
||||
let entry = parse_test_entry(&dir, "type/news.md", content);
|
||||
assert_eq!(entry.sidebar_label, Some("News".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sidebar_label_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert_eq!(entry.sidebar_label, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sidebar_label_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsidebar label: My Series\n---\n# Series\n";
|
||||
let entry = parse_test_entry(&dir, "type/series.md", content);
|
||||
assert!(entry.relationships.get("sidebar label").is_none());
|
||||
}
|
||||
|
||||
// --- template field tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: \"## Objective\\n\\n## Timeline\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_block_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Objective\n \n ## Timeline\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
let tmpl = entry.template.unwrap();
|
||||
assert!(tmpl.contains("## Objective"));
|
||||
assert!(tmpl.contains("## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Note\n";
|
||||
let entry = parse_test_entry(&dir, "type/note.md", content);
|
||||
assert_eq!(entry.template, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.relationships.get("template").is_none());
|
||||
}
|
||||
|
||||
// --- sort field tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_sort_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsort: \"modified:desc\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert_eq!(entry.sort, Some("modified:desc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sort_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert_eq!(entry.sort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.relationships.get("sort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_not_in_properties() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.properties.get("sort").is_none());
|
||||
}
|
||||
|
||||
// --- custom properties tests ---
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_scalar_values() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Priority: High
|
||||
Rating: 5
|
||||
Due date: 2026-06-15
|
||||
Reviewed: true
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "project/test.md", content);
|
||||
let expected: HashMap<String, serde_json::Value> = [
|
||||
("Priority".into(), serde_json::Value::String("High".into())),
|
||||
("Rating".into(), serde_json::json!(5)),
|
||||
(
|
||||
"Due date".into(),
|
||||
serde_json::Value::String("2026-06-15".into()),
|
||||
),
|
||||
("Reviewed".into(), serde_json::Value::Bool(true)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(entry.properties, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_skips_structural_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Is A: Project
|
||||
Status: Active
|
||||
Owner: Luca
|
||||
Cadence: Weekly
|
||||
Archived: false
|
||||
Priority: High
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "project/test.md", content);
|
||||
// Only Priority should survive — all others are structural
|
||||
assert_eq!(entry.properties.len(), 1);
|
||||
assert_eq!(
|
||||
entry.properties.get("Priority").and_then(|v| v.as_str()),
|
||||
Some("High")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_skips_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Mentor: "[[person/alice]]"
|
||||
Company: Acme Corp
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert!(entry.properties.get("Mentor").is_none());
|
||||
assert_eq!(
|
||||
entry.properties.get("Company").and_then(|v| v.as_str()),
|
||||
Some("Acme Corp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_properties_skips_arrays() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = r#"---
|
||||
Tags:
|
||||
- productivity
|
||||
- writing
|
||||
Company: Acme Corp
|
||||
---
|
||||
# Test
|
||||
"#;
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
assert!(entry.properties.get("Tags").is_none());
|
||||
assert_eq!(
|
||||
entry.properties.get("Company").and_then(|v| v.as_str()),
|
||||
Some("Acme Corp")
|
||||
);
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -42,6 +42,21 @@ fn try_purge_file(path: &Path) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Permanently delete a single note file.
|
||||
/// Returns the deleted path on success, or an error if the file doesn't exist.
|
||||
pub fn delete_note(path: &str) -> Result<String, String> {
|
||||
let file = Path::new(path);
|
||||
if !file.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
if !file.is_file() {
|
||||
return Err(format!("Path is not a file: {}", path));
|
||||
}
|
||||
fs::remove_file(file).map_err(|e| format!("Failed to delete {}: {}", path, e))?;
|
||||
log::info!("Permanently deleted note: {}", path);
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete those where
|
||||
/// `Trashed at` frontmatter is more than 30 days ago.
|
||||
/// Returns the list of deleted file paths.
|
||||
@@ -95,6 +110,28 @@ mod tests {
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_note_removes_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"doomed.md",
|
||||
"---\ntitle: Doomed\n---\n# Doomed\n",
|
||||
);
|
||||
let path = dir.path().join("doomed.md");
|
||||
assert!(path.exists());
|
||||
let result = delete_note(path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_note_nonexistent_file() {
|
||||
let result = delete_note("/nonexistent/path/that/does/not/exist.md");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_trash_deletes_old_trashed_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
170
src-tauri/src/vault_list.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VaultEntry {
|
||||
pub label: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct VaultList {
|
||||
pub vaults: Vec<VaultEntry>,
|
||||
pub active_vault: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hidden_defaults: Vec<String>,
|
||||
}
|
||||
|
||||
fn vault_list_path() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("vaults.json"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn load_at(path: &PathBuf) -> Result<VaultList, String> {
|
||||
if !path.exists() {
|
||||
return Ok(VaultList::default());
|
||||
}
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read vault list: {}", e))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault list: {}", e))
|
||||
}
|
||||
|
||||
fn save_at(path: &PathBuf, list: &VaultList) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(list)
|
||||
.map_err(|e| format!("Failed to serialize vault list: {}", e))?;
|
||||
fs::write(path, json).map_err(|e| format!("Failed to write vault list: {}", e))
|
||||
}
|
||||
|
||||
pub fn load_vault_list() -> Result<VaultList, String> {
|
||||
load_at(&vault_list_path()?)
|
||||
}
|
||||
|
||||
pub fn save_vault_list(list: &VaultList) -> Result<(), String> {
|
||||
save_at(&vault_list_path()?, list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn save_and_reload(list: &VaultList) -> VaultList {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("vaults.json");
|
||||
save_at(&path, list).unwrap();
|
||||
load_at(&path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_vault_list_is_empty() {
|
||||
let vl = VaultList::default();
|
||||
assert!(vl.vaults.is_empty());
|
||||
assert!(vl.active_vault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_preserves_data() {
|
||||
let list = VaultList {
|
||||
vaults: vec![
|
||||
VaultEntry {
|
||||
label: "My Vault".to_string(),
|
||||
path: "/Users/luca/Laputa".to_string(),
|
||||
},
|
||||
VaultEntry {
|
||||
label: "Work".to_string(),
|
||||
path: "/Users/luca/Work".to_string(),
|
||||
},
|
||||
],
|
||||
active_vault: Some("/Users/luca/Laputa".to_string()),
|
||||
hidden_defaults: vec![],
|
||||
};
|
||||
let loaded = save_and_reload(&list);
|
||||
assert_eq!(loaded.vaults.len(), 2);
|
||||
assert_eq!(loaded.vaults[0].label, "My Vault");
|
||||
assert_eq!(loaded.vaults[0].path, "/Users/luca/Laputa");
|
||||
assert_eq!(loaded.vaults[1].label, "Work");
|
||||
assert_eq!(loaded.active_vault.as_deref(), Some("/Users/luca/Laputa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_default_for_missing_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nonexistent.json");
|
||||
let result = load_at(&path).unwrap();
|
||||
assert!(result.vaults.is_empty());
|
||||
assert!(result.active_vault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_error_for_malformed_json() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("bad.json");
|
||||
fs::write(&path, "not valid json{{{").unwrap();
|
||||
let err = load_at(&path).unwrap_err();
|
||||
assert!(err.contains("Failed to parse vault list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_creates_parent_directories() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("dir").join("vaults.json");
|
||||
let list = VaultList {
|
||||
vaults: vec![VaultEntry {
|
||||
label: "Test".to_string(),
|
||||
path: "/tmp/test".to_string(),
|
||||
}],
|
||||
active_vault: None,
|
||||
hidden_defaults: vec![],
|
||||
};
|
||||
save_at(&path, &list).unwrap();
|
||||
assert!(path.exists());
|
||||
let loaded = load_at(&path).unwrap();
|
||||
assert_eq!(loaded.vaults.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_list_path_returns_ok() {
|
||||
let result = vault_list_path();
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_vault_list_roundtrip() {
|
||||
let list = VaultList::default();
|
||||
let loaded = save_and_reload(&list);
|
||||
assert!(loaded.vaults.is_empty());
|
||||
assert!(loaded.active_vault.is_none());
|
||||
assert!(loaded.hidden_defaults.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_defaults_roundtrip() {
|
||||
let list = VaultList {
|
||||
vaults: vec![],
|
||||
active_vault: None,
|
||||
hidden_defaults: vec!["/Users/luca/Documents/Getting Started".to_string()],
|
||||
};
|
||||
let loaded = save_and_reload(&list);
|
||||
assert_eq!(loaded.hidden_defaults.len(), 1);
|
||||
assert_eq!(
|
||||
loaded.hidden_defaults[0],
|
||||
"/Users/luca/Documents/Getting Started"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_legacy_format_without_hidden_defaults() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("legacy.json");
|
||||
// Simulate old format without hidden_defaults field
|
||||
fs::write(&path, r#"{"vaults":[],"active_vault":null}"#).unwrap();
|
||||
let loaded = load_at(&path).unwrap();
|
||||
assert!(loaded.hidden_defaults.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
"identifier": "club.refactoring.laputa",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5201",
|
||||
"devUrl": "http://localhost:5202",
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"beforeBuildCommand": "pnpm build"
|
||||
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
@@ -37,6 +37,9 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": {
|
||||
"resources/mcp-server/**/*": "mcp-server/"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||