Compare commits
145 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d94abae4 | ||
|
|
18b2aaedf6 | ||
|
|
1706300494 | ||
|
|
628ab76f09 | ||
|
|
d3896ddf01 | ||
|
|
7289a60db3 | ||
|
|
0cff626e48 | ||
|
|
5a4c986fe3 | ||
|
|
540b1400e2 | ||
|
|
826cda852a | ||
|
|
19583ea1f5 | ||
|
|
63eb4ff980 | ||
|
|
bbb29857b8 | ||
|
|
900ce7f66f | ||
|
|
eb55c5ec02 | ||
|
|
6f6e7d7cfe | ||
|
|
edcb306c7f | ||
|
|
97be1d1ca3 | ||
|
|
ea29a81d79 | ||
|
|
5b1fda2279 | ||
|
|
1cd596061a | ||
|
|
efb233b18f | ||
|
|
fd34df8db0 | ||
|
|
244deeb727 | ||
|
|
70f94d3a51 | ||
|
|
bb20ef17f6 | ||
|
|
15a1ba6829 | ||
|
|
e2c6669fd6 | ||
|
|
d4098d3308 | ||
|
|
1c3d677851 | ||
|
|
3da0b0e652 | ||
|
|
bc2f97d1d4 | ||
|
|
2fb6a30dff | ||
|
|
0206fb3720 | ||
|
|
51d1b28460 | ||
|
|
0183062467 | ||
|
|
1c244a85eb | ||
|
|
900755055b | ||
|
|
3af9a09d29 | ||
|
|
348b2654eb | ||
|
|
013cf0ffe1 | ||
|
|
418ea8a7a8 | ||
|
|
1e3c296787 | ||
|
|
32b4a90ae5 | ||
|
|
25260c7d58 | ||
|
|
ff3e7af65a | ||
|
|
75878c8b64 | ||
|
|
8f0c6e04fe | ||
|
|
adf45a51b5 | ||
|
|
96bc4e935a | ||
|
|
eab457b388 | ||
|
|
89da970455 | ||
|
|
6a57e83c99 | ||
|
|
d41e4ea34a | ||
|
|
c371e26dcb | ||
|
|
c78018b92a | ||
|
|
5e84ebc28a | ||
|
|
5775cb0c96 | ||
|
|
7ced48d001 | ||
|
|
533c9da4d0 | ||
|
|
e1afaaa5b6 | ||
|
|
914bcfdafd | ||
|
|
cfb047cb22 | ||
|
|
55ff9e6f5d | ||
|
|
d3ea632673 | ||
|
|
6d3d752fd5 | ||
|
|
26181b57b6 | ||
|
|
7efcaa11c4 | ||
|
|
197aad0e97 | ||
|
|
db47ffe454 | ||
|
|
008f067bf7 | ||
|
|
55a2509658 | ||
|
|
790f2ea85c | ||
|
|
ed5e6d6820 | ||
|
|
feb97caa87 | ||
|
|
5bcd344d5f | ||
|
|
816e3ca8bd | ||
|
|
ef148be94e | ||
|
|
ceee8b04ea | ||
|
|
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 |
@@ -1 +1 @@
|
||||
81859
|
||||
33549
|
||||
|
||||
9
.codescenerc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"exclude": [
|
||||
"tools/",
|
||||
"scripts/",
|
||||
"src-tauri/gen/",
|
||||
"coverage/",
|
||||
"dist/"
|
||||
]
|
||||
}
|
||||
3
.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
|
||||
|
||||
|
||||
27
.github/workflows/release.yml
vendored
@@ -55,6 +55,11 @@ jobs:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Bun (required for bundle-qmd.sh)
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -80,6 +85,24 @@ jobs:
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Import Apple Developer certificate into keychain
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
# Decode and import the certificate so codesign can use it in beforeBuildCommand
|
||||
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
|
||||
KEYCHAIN_PASSWORD="$(uuidgen)"
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Tauri app (with signing + notarization)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -153,7 +176,7 @@ jobs:
|
||||
REPO="refactoringhq/laputa-app"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
|
||||
cat > latest.json << EOF
|
||||
{
|
||||
@@ -163,7 +186,7 @@ jobs:
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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/
|
||||
|
||||
@@ -47,26 +47,26 @@ fi
|
||||
|
||||
# ── 0. TypeScript + Vite build ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "📦 [0/4] TypeScript + Vite build..."
|
||||
echo "📦 [0/5] TypeScript + Vite build..."
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm build
|
||||
echo " ✅ Build OK"
|
||||
|
||||
# ── 1. Frontend coverage (≥70%) — includes all unit tests ───────────────
|
||||
echo ""
|
||||
echo "📊 [1/4] Frontend tests + coverage (≥70%)..."
|
||||
echo "📊 [1/5] Frontend tests + coverage (≥70%)..."
|
||||
pnpm test:coverage --silent
|
||||
echo " ✅ Frontend coverage OK"
|
||||
|
||||
# ── 2. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
|
||||
echo ""
|
||||
if [ "$RUST_CHANGED" = true ]; then
|
||||
echo "🔧 [2/4] Clippy + rustfmt..."
|
||||
echo "🔧 [2/5] Clippy + rustfmt..."
|
||||
cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
|
||||
cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
|
||||
echo " ✅ Rust lint OK"
|
||||
else
|
||||
echo "⏭️ [2/4] Rust lint — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 3. Rust coverage (≥85% lines) ──────────────────────────────────────
|
||||
@@ -75,9 +75,9 @@ if [ "$RUST_CHANGED" = true ]; then
|
||||
LLVM_COV_FLAGS="--no-clean"
|
||||
if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then
|
||||
LLVM_COV_FLAGS=""
|
||||
echo "🦀 [3/4] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
|
||||
else
|
||||
echo "🦀 [3/4] Rust coverage (≥85%, incremental)..."
|
||||
echo "🦀 [3/5] Rust coverage (≥85%, incremental)..."
|
||||
fi
|
||||
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
@@ -90,12 +90,23 @@ if [ "$RUST_CHANGED" = true ]; then
|
||||
-- --test-threads=1
|
||||
echo " ✅ Rust coverage OK"
|
||||
else
|
||||
echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
|
||||
fi
|
||||
|
||||
# ── 4. CodeScene code health gate (≥9.2) ────────────────────────────────
|
||||
# ── 4. Playwright smoke tests (if any exist) ──────────────────────────
|
||||
echo ""
|
||||
echo "🏥 [4/4] CodeScene code health gate (≥9.2)..."
|
||||
SMOKE_FILES=$(find tests/smoke -name '*.spec.ts' 2>/dev/null | head -1)
|
||||
if [ -n "$SMOKE_FILES" ]; then
|
||||
echo "🎭 [4/5] Playwright smoke tests..."
|
||||
pnpm playwright:smoke
|
||||
echo " ✅ Smoke tests OK"
|
||||
else
|
||||
echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)"
|
||||
fi
|
||||
|
||||
# ── 5. CodeScene code health gate (≥9.2) ────────────────────────────────
|
||||
echo ""
|
||||
echo "🏥 [5/5] CodeScene code health gate (≥9.2)..."
|
||||
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
|
||||
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
|
||||
else
|
||||
|
||||
141
CLAUDE.md
@@ -15,11 +15,40 @@ pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix stru
|
||||
|
||||
**CI is a safety net, not a discovery tool.** If CI catches something you didn't catch locally, that's a process failure. All these tools are available locally — use them while you code, not just at the end.
|
||||
|
||||
## ⛔ BEFORE FIRING laputa-task-done — QA on real vault
|
||||
## ⛔ BEFORE FIRING laputa-task-done — Two-phase QA (mandatory)
|
||||
|
||||
> **⚠️ 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`.
|
||||
### Phase 1: Playwright browser QA (headless, you do this yourself)
|
||||
|
||||
Test every acceptance criterion using Playwright against the dev server **before** marking done. This catches 80% of bugs before Brian sees them.
|
||||
|
||||
```bash
|
||||
# 1. Start the dev server (use your worktree port)
|
||||
pnpm dev --port <N> &
|
||||
DEV_PID=$!
|
||||
sleep 3 # wait for vite to be ready
|
||||
|
||||
# 2. Run Playwright smoke test for this task
|
||||
BASE_URL="http://localhost:<N>" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
# 3. Or run all smoke tests
|
||||
BASE_URL="http://localhost:<N>" pnpm playwright:smoke
|
||||
|
||||
kill $DEV_PID
|
||||
```
|
||||
|
||||
**What to test in Playwright:**
|
||||
- Every command palette entry from the spec → open `Cmd+K`, type the command name, verify it appears and executes
|
||||
- Every keyboard shortcut → send keydown events, verify UI state changes
|
||||
- Every UI element described in the spec → verify it renders, is focusable, responds to Tab
|
||||
- Edge cases: empty state, long text, rapid keypresses
|
||||
|
||||
**Playwright is non-negotiable even if tests pass.** Unit tests verify code; Playwright verifies the user experience in the real browser. Both are required.
|
||||
|
||||
> **⚠️ Browser dev server limits**: the dev server uses mock Tauri handlers (`src/mock-tauri.ts`) — file system operations, git commands, and native dialogs are mocked. Test those via `pnpm tauri dev` in Phase 2 if the task touches them.
|
||||
|
||||
### Phase 2: Native Tauri QA (Brian does this after you push)
|
||||
|
||||
Brian installs the release build and runs keyboard-only QA on the native app. You don't do Phase 2 — but Phase 1 must pass before you fire the done signal, or Brian's QA will fail and the task goes back to To Rework.
|
||||
|
||||
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`
|
||||
@@ -29,6 +58,19 @@ pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix stru
|
||||
6. If task touches file save: verify `git -C ~/Laputa diff` shows changes
|
||||
7. If QA fails → fix and re-run. Do NOT fire the signal until it passes.
|
||||
|
||||
**⚠️ QA ≠ tests. QA means using the app as a user.**
|
||||
- "Tests pass" is NOT QA. Tests verify code, QA verifies the user experience.
|
||||
- The QA comment must describe what you did as a user: "Opened app → Cmd+K → typed 'Trash' → pressed Enter → note disappeared from list → restarted app → note still not visible"
|
||||
- Every QA comment must include: the exact keyboard/command palette steps used, what was visible before and after, and any edge case tested.
|
||||
- If you cannot test a feature using keyboard only (osascript shortcuts + command palette), the feature is not keyboard-first → QA fails.
|
||||
|
||||
**⚠️ Test in a clean environment when the feature depends on state.**
|
||||
If a feature involves indexing, fresh installs, first-time setup, or anything that only runs once:
|
||||
- **Do not test in the existing dev vault** — it already has the state you're trying to test.
|
||||
- **Create a new empty vault** for the test: Cmd+K → "New Vault" (or equivalent), pick a temp folder like `/tmp/test-vault-<slug>`, then test the full first-time flow from scratch.
|
||||
- This applies to: search indexing, vault init, getting-started setup, any "on first open" logic.
|
||||
- If you can't reproduce the fresh-install scenario locally, the feature is untestable → do not fire done.
|
||||
|
||||
Fire done signal only after QA passes:
|
||||
```bash
|
||||
rm -f /tmp/laputa-qa.lock
|
||||
@@ -74,13 +116,47 @@ Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with
|
||||
- **Never develop on `main`** — always on `task/<slug>` branch
|
||||
- **Commit every 20–30 min** — atomic commits, one concern per commit (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`)
|
||||
- **Update docs/** when changing architecture, abstractions, or significant design
|
||||
- **Test as you go** — write tests alongside code, not after
|
||||
|
||||
## Testing
|
||||
## TDD — Red/Green/Refactor (mandatory)
|
||||
|
||||
**Always use test-driven development.** No production code without a failing test first.
|
||||
|
||||
The loop:
|
||||
1. **Red** — write a failing test that describes the behavior you want. Run it, confirm it fails for the right reason.
|
||||
2. **Green** — write the minimum code to make the test pass. No more, no less.
|
||||
3. **Refactor** — clean up the code (extract, rename, simplify) while keeping tests green.
|
||||
4. **Commit** — one red/green/refactor cycle = one atomic commit.
|
||||
5. Repeat.
|
||||
|
||||
**Why this matters:**
|
||||
- Forces you to think about behavior before implementation
|
||||
- Produces only code that's actually needed (no speculative abstractions)
|
||||
- Tests written first are always behavioral and structure-insensitive by construction
|
||||
- Tiny cycles = fast feedback, smaller diffs, easier to review
|
||||
|
||||
**For bug fixes:**
|
||||
1. Write a failing test that reproduces the bug (this is the regression test)
|
||||
2. Fix the bug until the test passes
|
||||
3. Commit both together: `fix: [bug] — regression test added`
|
||||
|
||||
**For Rust:**
|
||||
```bash
|
||||
cargo watch -x test # run tests on every save
|
||||
```
|
||||
|
||||
**For frontend:**
|
||||
```bash
|
||||
pnpm test --watch # run tests on every save
|
||||
```
|
||||
|
||||
**When to deviate:** Pure UI layout/styling work with no logic is the only exception. Everything else — hooks, utilities, Rust commands, state management — must be TDD.
|
||||
|
||||
## Testing (quality bar)
|
||||
|
||||
- Unit tests must cover real business logic, not "component renders"
|
||||
- Every bug fixed manually → add a regression test
|
||||
- Every new feature → new tests for new behavior paths
|
||||
- Tests test **behavior** (what the code does), not **structure** (how it does it)
|
||||
- Every bug fixed → regression test that would have caught it
|
||||
- Every new feature → TDD from the start (see above)
|
||||
- `pnpm test:coverage` and `cargo llvm-cov` must pass before committing
|
||||
|
||||
## Design File (every UI task)
|
||||
@@ -103,6 +179,33 @@ node -e "const f=JSON.parse(require('fs').readFileSync('ui-design.pen','utf8'));
|
||||
echo '{"children":[],"variables":{}}' > design/<slug>.pen
|
||||
```
|
||||
|
||||
## Vault File Retrocompatibility (mandatory for every feature that adds vault files)
|
||||
|
||||
Laputa vaults are long-lived. New app versions must work on existing vaults that were created before a feature existed.
|
||||
|
||||
**Rule: never assume a vault file exists. Always auto-create if missing.**
|
||||
|
||||
Every feature that depends on a vault file or folder must:
|
||||
1. **Auto-bootstrap on vault open** — check if the required file/folder exists; if not, create it with defaults. This must be silent and non-blocking.
|
||||
2. **Be idempotent** — creating defaults must be safe to run multiple times (never overwrite user data).
|
||||
3. **Expose a repair command** — add a `Cmd+K` command like "Restore Default Themes" or "Repair Vault Config" that explicitly re-creates missing files. Users can run this if something is broken.
|
||||
|
||||
**General "Repair Vault" command** — when adding a new vault file dependency, register it with the central repair system so that `Cmd+K → "Repair Vault"` fixes everything in one shot.
|
||||
|
||||
**Pattern:**
|
||||
```
|
||||
on vault open:
|
||||
if file X does not exist → create X with defaults ← silent auto-repair
|
||||
if file X exists but is malformed → log warning, use defaults (don't crash)
|
||||
|
||||
on "Repair Vault" command:
|
||||
for each known vault file/folder:
|
||||
if missing → create with defaults
|
||||
if present → leave untouched (idempotent)
|
||||
```
|
||||
|
||||
This principle applies to: themes, config files, type files, any `.laputa/` subfolder, or any file Laputa expects to find in a vault.
|
||||
|
||||
## macOS / Tauri Gotchas
|
||||
|
||||
- `Option+N` on macOS → special chars (`¡`, `™`), not `key:'N'`. Use `e.code` or `Cmd+N`.
|
||||
@@ -118,6 +221,28 @@ bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/click.sh 400 300 # logical coords
|
||||
```
|
||||
|
||||
## Menu Bar Discoverability (mandatory for every new command)
|
||||
|
||||
The command palette is powerful but not discoverable — users must already know a command exists to find it. The macOS menu bar is where users discover what an app can do.
|
||||
|
||||
**Rule: every significant command palette entry must also appear in the menu bar.**
|
||||
|
||||
When adding a new command to the palette:
|
||||
1. **Identify the right menu bar group** — File, Edit, View, Note, Vault, or create a new group if needed
|
||||
2. **Add a menu item** with the same label as the palette command
|
||||
3. **Show the keyboard shortcut** next to the menu item (if one exists)
|
||||
4. **If no direct shortcut exists**, still add the menu item — it's discoverable and triggers the same action
|
||||
|
||||
The menu bar should be organized around what Laputa does:
|
||||
- **File** — new note, open vault, switch vault, close
|
||||
- **Edit** — undo, redo, find, note actions (rename, trash, duplicate)
|
||||
- **View** — view modes, zoom, sidebar, panels
|
||||
- **Note** — note-specific actions (move to trash, archive, properties)
|
||||
- **Vault** — vault management (themes, config, repair, sync)
|
||||
- **Window / Help** — standard macOS items
|
||||
|
||||
**This is a QA requirement:** before marking any task done, verify that every new command palette entry has a corresponding menu bar item.
|
||||
|
||||
## 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).
|
||||
|
||||
3
demo-vault-v2/.laputa/settings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"theme": null
|
||||
}
|
||||
33
demo-vault-v2/_themes/dark.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/default.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/minimal.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-2.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-3.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled-4.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
33
demo-vault-v2/_themes/untitled.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"colors": {
|
||||
"accent": "#EBEBEA",
|
||||
"accent-foreground": "#37352F",
|
||||
"background": "#FFFFFF",
|
||||
"border": "#E9E9E7",
|
||||
"card": "#FFFFFF",
|
||||
"destructive": "#E03E3E",
|
||||
"foreground": "#37352F",
|
||||
"input": "#E9E9E7",
|
||||
"muted": "#F0F0EF",
|
||||
"muted-foreground": "#787774",
|
||||
"popover": "#FFFFFF",
|
||||
"primary": "#155DFF",
|
||||
"primary-foreground": "#FFFFFF",
|
||||
"ring": "#155DFF",
|
||||
"secondary": "#EBEBEA",
|
||||
"secondary-foreground": "#37352F",
|
||||
"sidebar-accent": "#EBEBEA",
|
||||
"sidebar-background": "#F7F6F3",
|
||||
"sidebar-border": "#E9E9E7",
|
||||
"sidebar-foreground": "#37352F"
|
||||
},
|
||||
"description": "Light theme with warm, paper-like tones",
|
||||
"name": "Untitled Theme",
|
||||
"spacing": {
|
||||
"sidebar-width": "250px"
|
||||
},
|
||||
"typography": {
|
||||
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"font-size-base": "14px"
|
||||
}
|
||||
}
|
||||
5
demo-vault-v2/config/ui.config.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: config
|
||||
zoom: 1.3
|
||||
view_mode: all
|
||||
---
|
||||
@@ -4,6 +4,8 @@ Is A: Note
|
||||
Author: "Clayton Christensen"
|
||||
Topics: ["[[topic-saas-business]]"]
|
||||
URL: "https://example.com/innovators-dilemma"
|
||||
trashed: true
|
||||
trashed_at: 2026-03-04
|
||||
---
|
||||
# The Innovator's Dilemma
|
||||
*Clayton Christensen*
|
||||
|
||||
5
demo-vault-v2/note/wikilinks-qa-test.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Wikilinks QA Test
|
||||
|
||||
See [[ProjectX]] and [[Team Goals|Goals]] for details on the project timeline.
|
||||
|
||||
This is a test note for QA purposes.
|
||||
54
demo-vault-v2/theme/dark.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
Is A: Theme
|
||||
Description: Dark variant with deep navy tones
|
||||
background: "#0f0f1a"
|
||||
foreground: "#e0e0e0"
|
||||
card: "#16162a"
|
||||
popover: "#1e1e3a"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#2a2a4a"
|
||||
secondary-foreground: "#e0e0e0"
|
||||
muted: "#1e1e3a"
|
||||
muted-foreground: "#888888"
|
||||
accent: "#2a2a4a"
|
||||
accent-foreground: "#e0e0e0"
|
||||
destructive: "#f44336"
|
||||
border: "#2a2a4a"
|
||||
input: "#2a2a4a"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#1a1a2e"
|
||||
sidebar-foreground: "#e0e0e0"
|
||||
sidebar-border: "#2a2a4a"
|
||||
sidebar-accent: "#2a2a4a"
|
||||
text-primary: "#e0e0e0"
|
||||
text-secondary: "#888888"
|
||||
text-muted: "#666666"
|
||||
text-heading: "#e0e0e0"
|
||||
bg-primary: "#0f0f1a"
|
||||
bg-sidebar: "#1a1a2e"
|
||||
bg-hover: "#2a2a4a"
|
||||
bg-hover-subtle: "#1e1e3a"
|
||||
bg-selected: "#155DFF22"
|
||||
border-primary: "#2a2a4a"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#f44336"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF33"
|
||||
accent-green-light: "#00B38B33"
|
||||
accent-purple-light: "#A932FF33"
|
||||
accent-red-light: "#f4433633"
|
||||
accent-yellow-light: "#F0B10033"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14px
|
||||
editor-font-size: 16
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720
|
||||
---
|
||||
|
||||
# Dark Theme
|
||||
|
||||
A dark theme with deep navy tones for comfortable night-time reading.
|
||||
54
demo-vault-v2/theme/default.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
Is A: Theme
|
||||
Description: Light theme with warm, paper-like tones
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#EBEBEA"
|
||||
secondary-foreground: "#37352F"
|
||||
muted: "#F0F0EF"
|
||||
muted-foreground: "#787774"
|
||||
accent: "#EBEBEA"
|
||||
accent-foreground: "#37352F"
|
||||
destructive: "#E03E3E"
|
||||
border: "#E9E9E7"
|
||||
input: "#E9E9E7"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#F7F6F3"
|
||||
sidebar-foreground: "#37352F"
|
||||
sidebar-border: "#E9E9E7"
|
||||
sidebar-accent: "#EBEBEA"
|
||||
text-primary: "#37352F"
|
||||
text-secondary: "#787774"
|
||||
text-muted: "#B4B4B4"
|
||||
text-heading: "#37352F"
|
||||
bg-primary: "#FFFFFF"
|
||||
bg-sidebar: "#F7F6F3"
|
||||
bg-hover: "#EBEBEA"
|
||||
bg-hover-subtle: "#F0F0EF"
|
||||
bg-selected: "#E8F4FE"
|
||||
border-primary: "#E9E9E7"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#E03E3E"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF14"
|
||||
accent-green-light: "#00B38B14"
|
||||
accent-purple-light: "#A932FF14"
|
||||
accent-red-light: "#E03E3E14"
|
||||
accent-yellow-light: "#F0B10014"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14px
|
||||
editor-font-size: 16
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720
|
||||
---
|
||||
|
||||
# Default Theme
|
||||
|
||||
The default light theme for Laputa. Clean and warm, inspired by Notion.
|
||||
54
demo-vault-v2/theme/minimal.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
Is A: Theme
|
||||
Description: High contrast, minimal chrome
|
||||
background: "#FAFAFA"
|
||||
foreground: "#111111"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#000000"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#F0F0F0"
|
||||
secondary-foreground: "#111111"
|
||||
muted: "#F5F5F5"
|
||||
muted-foreground: "#666666"
|
||||
accent: "#F0F0F0"
|
||||
accent-foreground: "#111111"
|
||||
destructive: "#CC0000"
|
||||
border: "#E0E0E0"
|
||||
input: "#E0E0E0"
|
||||
ring: "#000000"
|
||||
sidebar: "#F5F5F5"
|
||||
sidebar-foreground: "#111111"
|
||||
sidebar-border: "#E0E0E0"
|
||||
sidebar-accent: "#E8E8E8"
|
||||
text-primary: "#111111"
|
||||
text-secondary: "#666666"
|
||||
text-muted: "#999999"
|
||||
text-heading: "#111111"
|
||||
bg-primary: "#FAFAFA"
|
||||
bg-sidebar: "#F5F5F5"
|
||||
bg-hover: "#EBEBEB"
|
||||
bg-hover-subtle: "#F5F5F5"
|
||||
bg-selected: "#00000014"
|
||||
border-primary: "#E0E0E0"
|
||||
accent-blue: "#000000"
|
||||
accent-green: "#006600"
|
||||
accent-orange: "#996600"
|
||||
accent-red: "#CC0000"
|
||||
accent-purple: "#660099"
|
||||
accent-yellow: "#996600"
|
||||
accent-blue-light: "#00000014"
|
||||
accent-green-light: "#00660014"
|
||||
accent-purple-light: "#66009914"
|
||||
accent-red-light: "#CC000014"
|
||||
accent-yellow-light: "#99660014"
|
||||
font-family: "'SF Mono', 'Menlo', monospace"
|
||||
font-size-base: 13px
|
||||
editor-font-size: 15
|
||||
editor-line-height: 1.6
|
||||
editor-max-width: 680
|
||||
---
|
||||
|
||||
# Minimal Theme
|
||||
|
||||
High contrast, minimal chrome. Monospace typography throughout.
|
||||
1
design/archived-note-indicator.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/mcp-autodetect-status-bar.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/themes-editable.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
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":{}}
|
||||
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/', 'tools/']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Laputa MCP Server — provides vault operation tools for AI assistants.
|
||||
* Laputa MCP Server — lightweight vault tools for AI agents.
|
||||
*
|
||||
* Usage:
|
||||
* VAULT_PATH=/path/to/vault node index.js
|
||||
* The agent has full shell access (bash, read, write, edit).
|
||||
* These MCP tools provide Laputa-specific capabilities that
|
||||
* native tools cannot replace:
|
||||
*
|
||||
* Tools:
|
||||
* - open_note / read_note: Read a note by path
|
||||
* - create_note: Create a new note with title and optional frontmatter
|
||||
* - search_notes: Search notes by title or content
|
||||
* - append_to_note: Append text to an existing note
|
||||
* - edit_note_frontmatter: Merge a patch into a note's YAML frontmatter
|
||||
* - delete_note: Delete a note file
|
||||
* - link_notes: Add a title to an array property in a note's frontmatter
|
||||
* - list_notes: List all notes, optionally filtered by type
|
||||
* - vault_context: Get vault types and recent notes
|
||||
* - ui_open_note / ui_open_tab / ui_highlight / ui_set_filter: UI actions
|
||||
* - search_notes: full-text search across vault notes
|
||||
* - get_vault_context: vault structure overview (types, note count, folders)
|
||||
* - get_note: parsed frontmatter + content (convenience over raw cat)
|
||||
* - open_note: signal Laputa UI to open a note as a tab
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
@@ -23,64 +17,47 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote,
|
||||
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { startUiBridge } from './ws-bridge.js'
|
||||
import WebSocket from 'ws'
|
||||
import { searchNotes, getNote, vaultContext } from './vault.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
|
||||
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions
|
||||
const uiBridge = startUiBridge(WS_UI_PORT)
|
||||
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
|
||||
// The bridge relays messages to all other clients (the React frontend).
|
||||
let uiSocket = null
|
||||
const RECONNECT_INTERVAL_MS = 3000
|
||||
|
||||
function connectUiBridge() {
|
||||
try {
|
||||
const ws = new WebSocket(WS_UI_URL)
|
||||
ws.on('open', () => {
|
||||
uiSocket = ws
|
||||
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
|
||||
})
|
||||
ws.on('close', () => {
|
||||
uiSocket = null
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
})
|
||||
ws.on('error', () => {
|
||||
// Silent — bridge may not be running yet, will retry
|
||||
})
|
||||
} catch {
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
connectUiBridge()
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
|
||||
for (const client of uiBridge.clients) {
|
||||
if (client.readyState === 1) client.send(msg)
|
||||
}
|
||||
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
|
||||
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
|
||||
}
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'open_note',
|
||||
description: 'Open and read a note from the vault by its relative path',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'read_note',
|
||||
description: 'Read the full content of a note',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_note',
|
||||
description: 'Create a new note in the vault with a title and optional frontmatter',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path for the new note (e.g. "note/my-idea.md")' },
|
||||
title: { type: 'string', description: 'Title of the note' },
|
||||
is_a: { type: 'string', description: 'Entity type (Project, Note, Experiment, etc.)' },
|
||||
},
|
||||
required: ['path', 'title'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_notes',
|
||||
description: 'Search notes in the vault by title or content',
|
||||
description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -91,72 +68,24 @@ const TOOLS = [
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'append_to_note',
|
||||
description: 'Append text to the end of an existing note',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
text: { type: 'string', description: 'Text to append' },
|
||||
},
|
||||
required: ['path', 'text'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'edit_note_frontmatter',
|
||||
description: 'Merge a patch object into a note\'s YAML frontmatter',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
patch: { type: 'object', description: 'Key-value pairs to merge into frontmatter' },
|
||||
},
|
||||
required: ['path', 'patch'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete_note',
|
||||
description: 'Delete a note file from the vault',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note to delete' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'link_notes',
|
||||
description: 'Add a target title to an array property in a note\'s frontmatter (e.g. add "Marco" to people: [])',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source_path: { type: 'string', description: 'Relative path to the source note' },
|
||||
property: { type: 'string', description: 'Frontmatter property name (e.g. "people", "tags")' },
|
||||
target_title: { type: 'string', description: 'Title to add to the array' },
|
||||
},
|
||||
required: ['source_path', 'property', 'target_title'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_notes',
|
||||
description: 'List all notes in the vault, optionally filtered by type frontmatter field',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type_filter: { type: 'string', description: 'Filter by type frontmatter value' },
|
||||
sort: { type: 'string', enum: ['title', 'mtime'], description: 'Sort order (default: title)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'vault_context',
|
||||
description: 'Get vault context: unique entity types and 20 most recently modified notes',
|
||||
name: 'get_vault_context',
|
||||
description: 'Get vault orientation: entity types, total note count, top-level folders, and 20 most recently modified notes.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'ui_open_note',
|
||||
description: 'Open a note in the Laputa UI editor',
|
||||
name: 'get_note',
|
||||
description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'open_note',
|
||||
description: 'Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -165,69 +94,13 @@ const TOOLS = [
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ui_open_tab',
|
||||
description: 'Open a note in a new tab in the Laputa UI',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ui_highlight',
|
||||
description: 'Highlight a UI element in the Laputa interface',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'UI element to highlight' },
|
||||
path: { type: 'string', description: 'Relative path to the note (optional)' },
|
||||
},
|
||||
required: ['element'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ui_set_filter',
|
||||
description: 'Set the sidebar filter to show notes of a specific type',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Type to filter by' },
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: handleReadNote,
|
||||
read_note: handleReadNote,
|
||||
create_note: handleCreateNote,
|
||||
search_notes: handleSearchNotes,
|
||||
append_to_note: handleAppendToNote,
|
||||
edit_note_frontmatter: handleEditFrontmatter,
|
||||
delete_note: handleDeleteNote,
|
||||
link_notes: handleLinkNotes,
|
||||
list_notes: handleListNotes,
|
||||
vault_context: handleVaultContext,
|
||||
ui_open_note: handleUiOpenNote,
|
||||
ui_open_tab: handleUiOpenTab,
|
||||
ui_highlight: handleUiHighlight,
|
||||
ui_set_filter: handleUiSetFilter,
|
||||
}
|
||||
|
||||
async function handleReadNote(args) {
|
||||
const content = await readNote(VAULT_PATH, args.path)
|
||||
return { content: [{ type: 'text', text: content }] }
|
||||
}
|
||||
|
||||
async function handleCreateNote(args) {
|
||||
const frontmatter = {}
|
||||
if (args.is_a) frontmatter.is_a = args.is_a
|
||||
const absPath = await createNote(VAULT_PATH, args.path, args.title, frontmatter)
|
||||
return { content: [{ type: 'text', text: `Created note at ${absPath}` }] }
|
||||
get_vault_context: handleVaultContext,
|
||||
get_note: handleGetNote,
|
||||
open_note: handleOpenNote,
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
@@ -238,63 +111,25 @@ async function handleSearchNotes(args) {
|
||||
return { content: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
async function handleAppendToNote(args) {
|
||||
await appendToNote(VAULT_PATH, args.path, args.text)
|
||||
return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] }
|
||||
}
|
||||
|
||||
async function handleEditFrontmatter(args) {
|
||||
const updated = await editNoteFrontmatter(VAULT_PATH, args.path, args.patch)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(updated) }] }
|
||||
}
|
||||
|
||||
async function handleDeleteNote(args) {
|
||||
await deleteNote(VAULT_PATH, args.path)
|
||||
return { content: [{ type: 'text', text: `Deleted ${args.path}` }] }
|
||||
}
|
||||
|
||||
async function handleLinkNotes(args) {
|
||||
const arr = await linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title)
|
||||
return { content: [{ type: 'text', text: `${args.property}: [${arr.join(', ')}]` }] }
|
||||
}
|
||||
|
||||
async function handleListNotes(args) {
|
||||
const notes = await listNotes(VAULT_PATH, args.type_filter, args.sort)
|
||||
const text = notes.length === 0
|
||||
? 'No notes found.'
|
||||
: notes.map(n => `${n.title} (${n.path})${n.type ? ` [${n.type}]` : ''}`).join('\n')
|
||||
return { content: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
async function handleVaultContext() {
|
||||
const ctx = await vaultContext(VAULT_PATH)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
|
||||
}
|
||||
|
||||
function handleUiOpenNote(args) {
|
||||
broadcastUiAction('open_note', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in UI` }] }
|
||||
async function handleGetNote(args) {
|
||||
const note = await getNote(VAULT_PATH, args.path)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
|
||||
}
|
||||
|
||||
function handleUiOpenTab(args) {
|
||||
function handleOpenNote(args) {
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Opening tab for ${args.path}` }] }
|
||||
}
|
||||
|
||||
function handleUiHighlight(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
|
||||
}
|
||||
|
||||
function handleUiSetFilter(args) {
|
||||
broadcastUiAction('set_filter', { type: args.type })
|
||||
return { content: [{ type: 'text', text: `Filter set to ${args.type}` }] }
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'laputa-mcp-server', version: '0.1.0' },
|
||||
{ name: 'laputa-mcp-server', version: '0.2.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Vault operations — file I/O for Laputa markdown vault.
|
||||
* Vault operations — read-only helpers for Laputa markdown vault.
|
||||
* Write operations are handled by the agent's native bash/write/edit tools.
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
@@ -26,36 +27,20 @@ export async function findMarkdownFiles(dir) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a note's content by path (absolute or relative to vault).
|
||||
* Read a note with parsed frontmatter and content.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @returns {Promise<string>}
|
||||
* @returns {Promise<{path: string, frontmatter: Record<string, unknown>, content: string}>}
|
||||
*/
|
||||
export async function readNote(vaultPath, notePath) {
|
||||
export async function getNote(vaultPath, notePath) {
|
||||
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
|
||||
return fs.readFile(absPath, 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new note with optional frontmatter.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} relativePath
|
||||
* @param {string} title
|
||||
* @param {Record<string, string>} [frontmatter]
|
||||
* @returns {Promise<string>} The absolute path of the created file.
|
||||
*/
|
||||
export async function createNote(vaultPath, relativePath, title, frontmatter = {}) {
|
||||
const absPath = path.join(vaultPath, relativePath)
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true })
|
||||
|
||||
const fmEntries = { title, ...frontmatter }
|
||||
const fmLines = Object.entries(fmEntries)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n')
|
||||
|
||||
const content = `---\n${fmLines}\n---\n\n# ${title}\n\n`
|
||||
await fs.writeFile(absPath, content, 'utf-8')
|
||||
return absPath
|
||||
const raw = await fs.readFile(absPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
return {
|
||||
path: path.relative(vaultPath, absPath),
|
||||
frontmatter: parsed.data,
|
||||
content: parsed.content.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,110 +77,14 @@ export async function searchNotes(vaultPath, query, limit = 10) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Append text to the end of a note.
|
||||
* Get vault context: unique types, note count, top-level folders, and 20 most recent notes.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @param {string} text
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function appendToNote(vaultPath, notePath, text) {
|
||||
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
|
||||
const current = await fs.readFile(absPath, 'utf-8')
|
||||
const separator = current.endsWith('\n') ? '\n' : '\n\n'
|
||||
await fs.writeFile(absPath, current + separator + text + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a patch object into a note's YAML frontmatter.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @param {Record<string, unknown>} patch
|
||||
* @returns {Promise<Record<string, unknown>>} The updated frontmatter.
|
||||
*/
|
||||
export async function editNoteFrontmatter(vaultPath, notePath, patch) {
|
||||
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
|
||||
const raw = await fs.readFile(absPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
Object.assign(parsed.data, patch)
|
||||
const updated = matter.stringify(parsed.content, parsed.data)
|
||||
await fs.writeFile(absPath, updated, 'utf-8')
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a note file.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteNote(vaultPath, notePath) {
|
||||
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
|
||||
await fs.unlink(absPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a target title to an array property in a note's frontmatter.
|
||||
* Creates the property as an array if it doesn't exist.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} sourcePath
|
||||
* @param {string} property
|
||||
* @param {string} targetTitle
|
||||
* @returns {Promise<string[]>} The updated array.
|
||||
*/
|
||||
export async function linkNotes(vaultPath, sourcePath, property, targetTitle) {
|
||||
const absPath = path.isAbsolute(sourcePath) ? sourcePath : path.join(vaultPath, sourcePath)
|
||||
const raw = await fs.readFile(absPath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
const current = Array.isArray(parsed.data[property]) ? parsed.data[property] : []
|
||||
if (!current.includes(targetTitle)) {
|
||||
current.push(targetTitle)
|
||||
}
|
||||
parsed.data[property] = current
|
||||
const updated = matter.stringify(parsed.content, parsed.data)
|
||||
await fs.writeFile(absPath, updated, 'utf-8')
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* List all notes in the vault, optionally filtered by type.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} [typeFilter]
|
||||
* @param {string} [sort] - 'title' or 'mtime' (default: 'title')
|
||||
* @returns {Promise<Array<{path: string, title: string, type: string|null}>>}
|
||||
*/
|
||||
export async function listNotes(vaultPath, typeFilter, sort = 'title') {
|
||||
const files = await findMarkdownFiles(vaultPath)
|
||||
const notes = await Promise.all(files.map(async (filePath) => {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
const relativePath = path.relative(vaultPath, filePath)
|
||||
const title = parsed.data.title || extractTitle(raw, path.basename(filePath, '.md'))
|
||||
const type = parsed.data.type || parsed.data.is_a || null
|
||||
const stat = sort === 'mtime' ? await fs.stat(filePath) : null
|
||||
return { path: relativePath, title, type, mtime: stat?.mtimeMs ?? 0 }
|
||||
}))
|
||||
|
||||
const filtered = typeFilter
|
||||
? notes.filter(n => n.type === typeFilter)
|
||||
: notes
|
||||
|
||||
if (sort === 'mtime') {
|
||||
filtered.sort((a, b) => b.mtime - a.mtime)
|
||||
} else {
|
||||
filtered.sort((a, b) => a.title.localeCompare(b.title))
|
||||
}
|
||||
|
||||
return filtered.map(({ mtime: _mtime, ...rest }) => rest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vault context: unique types and 20 most recent notes.
|
||||
* @param {string} vaultPath
|
||||
* @returns {Promise<{types: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>}
|
||||
* @returns {Promise<{types: string[], noteCount: number, folders: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>}
|
||||
*/
|
||||
export async function vaultContext(vaultPath) {
|
||||
const files = await findMarkdownFiles(vaultPath)
|
||||
const typesSet = new Set()
|
||||
const foldersSet = new Set()
|
||||
const notesWithMtime = []
|
||||
|
||||
for (const filePath of files) {
|
||||
@@ -203,9 +92,12 @@ export async function vaultContext(vaultPath) {
|
||||
const parsed = matter(raw)
|
||||
const type = parsed.data.type || parsed.data.is_a || null
|
||||
if (type) typesSet.add(type)
|
||||
const rel = path.relative(vaultPath, filePath)
|
||||
const topFolder = rel.split(path.sep)[0]
|
||||
if (topFolder !== rel) foldersSet.add(topFolder + '/')
|
||||
const stat = await fs.stat(filePath)
|
||||
notesWithMtime.push({
|
||||
path: path.relative(vaultPath, filePath),
|
||||
path: rel,
|
||||
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
|
||||
type,
|
||||
mtime: stat.mtimeMs,
|
||||
@@ -215,7 +107,13 @@ export async function vaultContext(vaultPath) {
|
||||
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
|
||||
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
|
||||
|
||||
return { types: [...typesSet].sort(), recentNotes, vaultPath }
|
||||
return {
|
||||
types: [...typesSet].sort(),
|
||||
noteCount: files.length,
|
||||
folders: [...foldersSet].sort(),
|
||||
recentNotes,
|
||||
vaultPath,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
* 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,
|
||||
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
|
||||
getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
@@ -40,27 +40,16 @@ function broadcastUiAction(action, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildFrontmatter(args) {
|
||||
const fm = {}
|
||||
if (args.is_a) fm.is_a = args.is_a
|
||||
return fm
|
||||
}
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
|
||||
read_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
|
||||
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)),
|
||||
open_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
|
||||
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })),
|
||||
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch),
|
||||
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => ({ ok: true })),
|
||||
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title),
|
||||
list_notes: (args) => listNotes(VAULT_PATH, args.type_filter, args.sort),
|
||||
vault_context: () => vaultContext(VAULT_PATH),
|
||||
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { type: args.type }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
@@ -80,15 +69,41 @@ 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', (ws) => {
|
||||
console.error(`[ws-bridge] UI client connected on port ${port}`)
|
||||
// Relay: when a client sends a message, broadcast to all OTHER clients.
|
||||
// This allows the MCP stdio server (connected as a client) to reach the frontend.
|
||||
ws.on('message', (raw) => {
|
||||
for (const client of wss.clients) {
|
||||
if (client !== ws && client.readyState === 1) client.send(raw.toString())
|
||||
}
|
||||
})
|
||||
})
|
||||
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 +131,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())
|
||||
}
|
||||
|
||||
12
package.json
@@ -6,12 +6,14 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test tests/smoke/",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "husky"
|
||||
},
|
||||
@@ -20,6 +22,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 +56,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 +75,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",
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30000,
|
||||
testDir: './tests/smoke',
|
||||
timeout: 15_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:5201',
|
||||
headless: true,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
port: 5173,
|
||||
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`,
|
||||
url: process.env.BASE_URL || 'http://localhost:5201',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
})
|
||||
|
||||
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/')
|
||||
155
scripts/bundle-qmd.sh
Executable file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bundle qmd into a self-contained directory for Tauri resource embedding.
|
||||
#
|
||||
# Output: src-tauri/resources/qmd/
|
||||
# qmd — compiled standalone binary
|
||||
# node_modules/sqlite-vec/ — JS shim for sqlite-vec
|
||||
# node_modules/sqlite-vec-darwin-arm64/ — native .dylib (arm64)
|
||||
# node_modules/sqlite-vec-darwin-x64/ — native .dylib (x64)
|
||||
# node_modules/node-llama-cpp/ — stub (keyword search only)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="$SCRIPT_DIR/.."
|
||||
OUT="$ROOT/src-tauri/resources/qmd"
|
||||
|
||||
# ---------- locate tools ----------
|
||||
find_bun() {
|
||||
for c in \
|
||||
"$HOME/.bun/bin/bun" \
|
||||
"/opt/homebrew/bin/bun" \
|
||||
"/usr/local/bin/bun"; do
|
||||
[[ -x "$c" ]] && { echo "$c"; return 0; }
|
||||
done
|
||||
command -v bun 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
BUN=$(find_bun) || { echo "ERROR: bun not found — install from https://bun.sh" >&2; exit 1; }
|
||||
echo "Using bun: $BUN"
|
||||
|
||||
# ---------- locate qmd source ----------
|
||||
# Prefer bundled source in tools/qmd/ (works in CI and dev),
|
||||
# then fall back to globally installed qmd on dev machines.
|
||||
QMD_SRC=""
|
||||
for c in \
|
||||
"$ROOT/tools/qmd" \
|
||||
"$HOME/.bun/install/global/node_modules/qmd" \
|
||||
"/opt/homebrew/lib/node_modules/qmd" \
|
||||
"/usr/local/lib/node_modules/qmd"; do
|
||||
[[ -f "$c/src/qmd.ts" ]] && { QMD_SRC="$c"; break; }
|
||||
done
|
||||
|
||||
[[ -n "$QMD_SRC" ]] || { echo "ERROR: qmd source not found. tools/qmd/ is missing or incomplete." >&2; exit 1; }
|
||||
echo "Using qmd source: $QMD_SRC"
|
||||
|
||||
# Install qmd dependencies if needed (for CI where node_modules don't exist yet)
|
||||
if [[ ! -d "$QMD_SRC/node_modules" ]]; then
|
||||
echo "Installing qmd dependencies..."
|
||||
(cd "$QMD_SRC" && "$BUN" install --frozen-lockfile)
|
||||
fi
|
||||
|
||||
# ---------- compile ----------
|
||||
echo "Compiling qmd with bun build --compile..."
|
||||
mkdir -p "$OUT"
|
||||
|
||||
(cd "$QMD_SRC" && "$BUN" build --compile \
|
||||
"src/qmd.ts" \
|
||||
--outfile "$OUT/qmd" \
|
||||
--external node-llama-cpp \
|
||||
--external sqlite-vec \
|
||||
--external sqlite-vec-darwin-arm64 \
|
||||
--external sqlite-vec-darwin-x64)
|
||||
|
||||
chmod +x "$OUT/qmd"
|
||||
|
||||
# ---------- bundle sqlite-vec ----------
|
||||
echo "Bundling sqlite-vec native extensions..."
|
||||
|
||||
# Find sqlite-vec packages — prefer node_modules in QMD_SRC (after bun install),
|
||||
# fall back to bun global cache for dev machines.
|
||||
NM="$QMD_SRC/node_modules"
|
||||
|
||||
find_pkg() {
|
||||
local pkg="$1"
|
||||
# Check node_modules from bun install in QMD_SRC first
|
||||
if [[ -d "$NM/$pkg" ]]; then
|
||||
echo "$NM/$pkg"; return 0
|
||||
fi
|
||||
# Fall back to bun global cache
|
||||
local cache_dir
|
||||
cache_dir=$(find "$HOME/.bun/install/cache" -maxdepth 1 -name "${pkg}@*" -type d 2>/dev/null | head -1)
|
||||
[[ -n "$cache_dir" ]] && echo "$cache_dir" && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# sqlite-vec JS shim
|
||||
SQLVEC_DIR=$(find_pkg "sqlite-vec") || { echo "ERROR: sqlite-vec not found" >&2; exit 1; }
|
||||
mkdir -p "$OUT/node_modules/sqlite-vec"
|
||||
cp "$SQLVEC_DIR/index.mjs" "$OUT/node_modules/sqlite-vec/index.mjs"
|
||||
cp "$SQLVEC_DIR/package.json" "$OUT/node_modules/sqlite-vec/package.json"
|
||||
[[ -f "$SQLVEC_DIR/index.cjs" ]] && cp "$SQLVEC_DIR/index.cjs" "$OUT/node_modules/sqlite-vec/index.cjs"
|
||||
|
||||
# sqlite-vec-darwin-arm64
|
||||
ARM64_DIR=$(find_pkg "sqlite-vec-darwin-arm64") || true
|
||||
if [[ -n "$ARM64_DIR" ]]; then
|
||||
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-arm64"
|
||||
cp "$ARM64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-arm64/vec0.dylib"
|
||||
cp "$ARM64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-arm64/package.json"
|
||||
echo " ✓ arm64 dylib"
|
||||
fi
|
||||
|
||||
# sqlite-vec-darwin-x64
|
||||
X64_DIR=$(find_pkg "sqlite-vec-darwin-x64") || true
|
||||
if [[ -n "$X64_DIR" ]]; then
|
||||
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-x64"
|
||||
cp "$X64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-x64/vec0.dylib"
|
||||
cp "$X64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-x64/package.json"
|
||||
echo " ✓ x64 dylib"
|
||||
fi
|
||||
|
||||
# ---------- stub node-llama-cpp ----------
|
||||
echo "Creating node-llama-cpp stub (keyword search only)..."
|
||||
mkdir -p "$OUT/node_modules/node-llama-cpp"
|
||||
|
||||
cat > "$OUT/node_modules/node-llama-cpp/package.json" << 'PJSON'
|
||||
{"name":"node-llama-cpp","version":"0.0.0-stub","type":"module","main":"index.js"}
|
||||
PJSON
|
||||
|
||||
cat > "$OUT/node_modules/node-llama-cpp/index.js" << 'STUB'
|
||||
// Stub: node-llama-cpp not bundled — semantic search unavailable, keyword search works.
|
||||
const unavailable = (name) => (...args) => {
|
||||
throw new Error(`${name}() unavailable: node-llama-cpp not bundled. Keyword search still works.`);
|
||||
};
|
||||
export const getLlama = unavailable("getLlama");
|
||||
export const resolveModelFile = unavailable("resolveModelFile");
|
||||
export class LlamaChatSession {
|
||||
constructor() { throw new Error("LlamaChatSession unavailable"); }
|
||||
}
|
||||
export const LlamaLogLevel = { Error: 0, Warn: 1, Info: 2, Debug: 3 };
|
||||
STUB
|
||||
|
||||
# ---------- code signing (macOS) ----------
|
||||
# In CI (APPLE_SIGNING_IDENTITY set): sign with Developer ID + hardened runtime (required for notarization)
|
||||
# In dev (no identity): ad-hoc sign to remove quarantine
|
||||
if [[ "$(uname)" == "Darwin" ]] && command -v codesign &>/dev/null; then
|
||||
SIGN_ID="${APPLE_SIGNING_IDENTITY:--}"
|
||||
if [[ "$SIGN_ID" != "-" ]]; then
|
||||
echo "Signing bundled binaries with Developer ID: $SIGN_ID"
|
||||
SIGN_OPTS=(--force --sign "$SIGN_ID" --options runtime --timestamp)
|
||||
else
|
||||
echo "Ad-hoc signing bundled binaries (dev mode)..."
|
||||
SIGN_OPTS=(--force --sign -)
|
||||
fi
|
||||
codesign "${SIGN_OPTS[@]}" "$OUT/qmd" 2>/dev/null && echo " ✓ qmd signed" || echo " ⚠ qmd signing failed (non-fatal)"
|
||||
while IFS= read -r -d '' dylib; do
|
||||
codesign "${SIGN_OPTS[@]}" "$dylib" 2>/dev/null && echo " ✓ $(basename "$dylib") signed" || echo " ⚠ $(basename "$dylib") signing failed (non-fatal)"
|
||||
done < <(find "$OUT/node_modules" -name "*.dylib" -print0)
|
||||
fi
|
||||
|
||||
# ---------- summary ----------
|
||||
echo ""
|
||||
echo "qmd bundled → $OUT/"
|
||||
du -sh "$OUT/qmd"
|
||||
du -sh "$OUT/node_modules"
|
||||
echo "Done."
|
||||
4
src-tauri/.gitignore
vendored
@@ -2,3 +2,7 @@
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/gen/schemas
|
||||
|
||||
# Generated by build scripts
|
||||
/resources/mcp-server/
|
||||
/resources/qmd/
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
fn main() {
|
||||
let count = std::process::Command::new("git")
|
||||
.args(["rev-list", "--count", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "DEV".to_string());
|
||||
|
||||
println!("cargo:rustc-env=BUILD_NUMBER={}", count);
|
||||
|
||||
// Ensure resource directories exist for the Tauri build.
|
||||
// These are gitignored and populated by scripts (bundle-qmd.sh, bundle-mcp-server.mjs).
|
||||
// Without a placeholder, `tauri build` / `cargo test` fails if the scripts haven't run.
|
||||
for dir in ["resources/qmd", "resources/mcp-server"] {
|
||||
let path = std::path::Path::new(dir);
|
||||
if !path.exists() {
|
||||
std::fs::create_dir_all(path).ok();
|
||||
std::fs::write(path.join(".placeholder"), "").ok();
|
||||
}
|
||||
}
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
|
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 |
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -18,10 +19,21 @@ pub enum ClaudeStreamEvent {
|
||||
Init { session_id: String },
|
||||
/// Incremental text chunk.
|
||||
TextDelta { text: String },
|
||||
/// Incremental thinking/reasoning chunk.
|
||||
ThinkingDelta { text: String },
|
||||
/// A tool call started (agent mode only).
|
||||
ToolStart { tool_name: String, tool_id: String },
|
||||
ToolStart {
|
||||
tool_name: String,
|
||||
tool_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
input: Option<String>,
|
||||
},
|
||||
/// A tool call finished (agent mode only).
|
||||
ToolDone { tool_id: String },
|
||||
ToolDone {
|
||||
tool_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
output: Option<String>,
|
||||
},
|
||||
/// Final result text + session ID.
|
||||
Result { text: String, session_id: String },
|
||||
/// Something went wrong.
|
||||
@@ -117,7 +129,7 @@ where
|
||||
{
|
||||
let bin = find_claude_binary()?;
|
||||
let args = build_chat_args(&req);
|
||||
run_claude_subprocess(&bin, &args, &mut emit)
|
||||
run_claude_subprocess(&bin, &args, None, &mut emit)
|
||||
}
|
||||
|
||||
/// Build CLI arguments for a chat stream request.
|
||||
@@ -148,17 +160,18 @@ fn build_chat_args(req: &ChatStreamRequest) -> Vec<String> {
|
||||
args
|
||||
}
|
||||
|
||||
/// Spawn `claude -p` with MCP vault tools for an agent task and stream events.
|
||||
/// Spawn `claude -p` with full tool access and MCP vault tools for an agent task.
|
||||
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)
|
||||
run_claude_subprocess(&bin, &args, Some(&req.vault_path), &mut emit)
|
||||
}
|
||||
|
||||
/// Build CLI arguments for an agent stream request.
|
||||
/// Native tools (bash, read, write, edit) are enabled by default — no `--tools ""`.
|
||||
fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_config = build_mcp_config(&req.vault_path)?;
|
||||
|
||||
@@ -169,8 +182,6 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
"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(),
|
||||
@@ -207,23 +218,46 @@ fn build_mcp_config(vault_path: &str) -> Result<String, String> {
|
||||
serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}"))
|
||||
}
|
||||
|
||||
/// Mutable state accumulated across the JSON stream for a single subprocess.
|
||||
struct StreamState {
|
||||
session_id: String,
|
||||
/// Accumulates `input_json_delta` chunks keyed by tool_use id.
|
||||
tool_inputs: HashMap<String, String>,
|
||||
/// The tool_use id of the block currently being streamed.
|
||||
current_tool_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Core subprocess runner shared by chat and agent modes.
|
||||
fn run_claude_subprocess<F>(bin: &PathBuf, args: &[String], emit: &mut F) -> Result<String, String>
|
||||
/// When `cwd` is `Some`, the subprocess starts with that working directory.
|
||||
fn run_claude_subprocess<F>(
|
||||
bin: &PathBuf,
|
||||
args: &[String],
|
||||
cwd: Option<&str>,
|
||||
emit: &mut F,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
let mut child = Command::new(bin)
|
||||
.args(args)
|
||||
let mut cmd = Command::new(bin);
|
||||
cmd.args(args)
|
||||
.env_remove("CLAUDECODE") // prevent "nested session" guard
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
if let Some(dir) = cwd {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let mut child = cmd
|
||||
.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();
|
||||
let mut state = StreamState {
|
||||
session_id: String::new(),
|
||||
tool_inputs: HashMap::new(),
|
||||
current_tool_id: None,
|
||||
};
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
@@ -245,7 +279,7 @@ where
|
||||
Err(_) => continue, // skip non-JSON lines
|
||||
};
|
||||
|
||||
dispatch_event(&json, &mut session_id, emit);
|
||||
dispatch_event(&json, &mut state, emit);
|
||||
}
|
||||
|
||||
// Read stderr for potential error messages.
|
||||
@@ -257,7 +291,7 @@ where
|
||||
|
||||
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
|
||||
|
||||
if !status.success() && session_id.is_empty() {
|
||||
if !status.success() && state.session_id.is_empty() {
|
||||
let msg = if stderr_output.contains("not logged in")
|
||||
|| stderr_output.contains("authentication")
|
||||
|| stderr_output.contains("auth")
|
||||
@@ -273,11 +307,11 @@ where
|
||||
|
||||
emit(ClaudeStreamEvent::Done);
|
||||
|
||||
Ok(session_id)
|
||||
Ok(state.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)
|
||||
fn dispatch_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
@@ -287,7 +321,7 @@ where
|
||||
// --- 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();
|
||||
state.session_id = sid.to_string();
|
||||
emit(ClaudeStreamEvent::Init {
|
||||
session_id: sid.to_string(),
|
||||
});
|
||||
@@ -296,7 +330,7 @@ where
|
||||
|
||||
// --- Streaming partial events (text deltas, tool_use starts) ---
|
||||
"stream_event" => {
|
||||
dispatch_stream_event(json, emit);
|
||||
dispatch_stream_event(json, state, emit);
|
||||
}
|
||||
|
||||
// --- Tool progress (agent mode) ---
|
||||
@@ -307,6 +341,18 @@ where
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
input: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tool result (agent mode) ---
|
||||
"tool_result" => {
|
||||
if let Some(id) = json["tool_use_id"].as_str() {
|
||||
let output = extract_tool_result_text(json);
|
||||
emit(ClaudeStreamEvent::ToolDone {
|
||||
tool_id: id.to_string(),
|
||||
output,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -315,7 +361,7 @@ where
|
||||
"result" => {
|
||||
let sid = json["session_id"].as_str().unwrap_or("").to_string();
|
||||
if !sid.is_empty() {
|
||||
*session_id = sid.clone();
|
||||
state.session_id = sid.clone();
|
||||
}
|
||||
let text = json["result"].as_str().unwrap_or("").to_string();
|
||||
emit(ClaudeStreamEvent::Result {
|
||||
@@ -332,9 +378,11 @@ where
|
||||
if let (Some(id), Some(name)) =
|
||||
(block["id"].as_str(), block["name"].as_str())
|
||||
{
|
||||
let input = format_tool_input(&block["input"], state, id);
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -347,7 +395,7 @@ where
|
||||
}
|
||||
|
||||
/// Handle a `stream_event` (partial assistant message).
|
||||
fn dispatch_stream_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
fn dispatch_stream_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
|
||||
where
|
||||
F: FnMut(ClaudeStreamEvent),
|
||||
{
|
||||
@@ -357,29 +405,91 @@ where
|
||||
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(),
|
||||
});
|
||||
match delta["type"].as_str() {
|
||||
Some("text_delta") => {
|
||||
if let Some(text) = delta["text"].as_str() {
|
||||
emit(ClaudeStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some("thinking_delta") => {
|
||||
if let Some(text) = delta["thinking"].as_str() {
|
||||
emit(ClaudeStreamEvent::ThinkingDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some("input_json_delta") => {
|
||||
if let (Some(partial), Some(ref tid)) =
|
||||
(delta["partial_json"].as_str(), &state.current_tool_id)
|
||||
{
|
||||
state
|
||||
.tool_inputs
|
||||
.entry(tid.clone())
|
||||
.or_default()
|
||||
.push_str(partial);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"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()) {
|
||||
state.current_tool_id = Some(id.to_string());
|
||||
state.tool_inputs.entry(id.to_string()).or_default();
|
||||
emit(ClaudeStreamEvent::ToolStart {
|
||||
tool_name: name.to_string(),
|
||||
tool_id: id.to_string(),
|
||||
input: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_block_stop" => {
|
||||
state.current_tool_id = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the tool input string, preferring accumulated delta chunks over the
|
||||
/// block's `input` field (which may be empty at stream start).
|
||||
fn format_tool_input(
|
||||
block_input: &serde_json::Value,
|
||||
state: &StreamState,
|
||||
tool_id: &str,
|
||||
) -> Option<String> {
|
||||
if let Some(accumulated) = state.tool_inputs.get(tool_id) {
|
||||
if !accumulated.is_empty() {
|
||||
return Some(accumulated.clone());
|
||||
}
|
||||
}
|
||||
if !block_input.is_null() && block_input.as_object().is_some_and(|o| !o.is_empty()) {
|
||||
return Some(block_input.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract displayable text from a `tool_result` event.
|
||||
fn extract_tool_result_text(json: &serde_json::Value) -> Option<String> {
|
||||
// String content field
|
||||
if let Some(s) = json["content"].as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
// Array of content blocks (Claude format)
|
||||
if let Some(arr) = json["content"].as_array() {
|
||||
let texts: Vec<&str> = arr.iter().filter_map(|b| b["text"].as_str()).collect();
|
||||
if !texts.is_empty() {
|
||||
return Some(texts.join("\n"));
|
||||
}
|
||||
}
|
||||
// Fallback: "output" field
|
||||
json["output"].as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -408,12 +518,20 @@ mod tests {
|
||||
|
||||
// --- dispatch_event / dispatch_stream_event ---
|
||||
|
||||
fn new_state() -> StreamState {
|
||||
StreamState {
|
||||
session_id: String::new(),
|
||||
tool_inputs: HashMap::new(),
|
||||
current_tool_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 state = new_state();
|
||||
let mut events = vec![];
|
||||
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
|
||||
(sid, events)
|
||||
dispatch_event(&json, &mut state, &mut |e| events.push(e));
|
||||
(state.session_id, events)
|
||||
}
|
||||
|
||||
/// Run dispatch_event with a pre-set session_id.
|
||||
@@ -421,10 +539,23 @@ mod tests {
|
||||
json: serde_json::Value,
|
||||
initial_sid: &str,
|
||||
) -> (String, Vec<ClaudeStreamEvent>) {
|
||||
let mut sid = initial_sid.to_string();
|
||||
let mut state = new_state();
|
||||
state.session_id = initial_sid.to_string();
|
||||
let mut events = vec![];
|
||||
dispatch_event(&json, &mut sid, &mut |e| events.push(e));
|
||||
(sid, events)
|
||||
dispatch_event(&json, &mut state, &mut |e| events.push(e));
|
||||
(state.session_id, events)
|
||||
}
|
||||
|
||||
/// Run multiple dispatch_event calls sharing state (for multi-event sequences).
|
||||
fn run_dispatch_sequence(
|
||||
events_json: Vec<serde_json::Value>,
|
||||
) -> (StreamState, Vec<ClaudeStreamEvent>) {
|
||||
let mut state = new_state();
|
||||
let mut events = vec![];
|
||||
for json in &events_json {
|
||||
dispatch_event(json, &mut state, &mut |e| events.push(e));
|
||||
}
|
||||
(state, events)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -468,7 +599,7 @@ mod tests {
|
||||
"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")
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "read_note" && tool_id == "tool_abc")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -501,7 +632,7 @@ mod tests {
|
||||
"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")
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tool_xyz")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -523,7 +654,7 @@ mod tests {
|
||||
}));
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tu_1")
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id, .. } if tool_name == "search_notes" && tool_id == "tu_1")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -541,7 +672,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stream_event_non_text_delta_is_ignored() {
|
||||
fn dispatch_stream_event_input_json_delta_accumulates_silently() {
|
||||
// input_json_delta doesn't emit events directly — it accumulates in state
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } }
|
||||
@@ -566,6 +698,112 @@ mod tests {
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_tool_result_string_content() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tool_abc",
|
||||
"content": "Found 3 notes matching query"
|
||||
}));
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "tool_abc" && output.as_deref() == Some("Found 3 notes matching query"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_handles_tool_result_array_content() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tool_def",
|
||||
"content": [{ "type": "text", "text": "Line 1" }, { "type": "text", "text": "Line 2" }]
|
||||
}));
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolDone { output, .. }
|
||||
if output.as_deref() == Some("Line 1\nLine 2"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_event_tool_result_missing_tool_id_is_ignored() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "tool_result", "content": "result text"
|
||||
}));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_accumulates_input_json_deltas() {
|
||||
let (_, events) = run_dispatch_sequence(vec![
|
||||
// Start tool_use block
|
||||
serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "search_notes", "input": {} } }
|
||||
}),
|
||||
// Input delta chunks
|
||||
serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "{\"query\":" } }
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_delta", "delta": { "type": "input_json_delta", "partial_json": "\"test\"}" } }
|
||||
}),
|
||||
// Stop block
|
||||
serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_stop" }
|
||||
}),
|
||||
// Assistant message triggers ToolStart with accumulated input
|
||||
serde_json::json!({
|
||||
"type": "assistant",
|
||||
"message": { "content": [
|
||||
{ "type": "tool_use", "id": "t1", "name": "search_notes", "input": { "query": "test" } }
|
||||
] }
|
||||
}),
|
||||
]);
|
||||
// First event: ToolStart with no input (from content_block_start)
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
ClaudeStreamEvent::ToolStart { input: None, .. }
|
||||
));
|
||||
// Second event: ToolStart with accumulated input (from assistant)
|
||||
assert!(
|
||||
matches!(&events[1], ClaudeStreamEvent::ToolStart { input: Some(inp), .. }
|
||||
if inp == "{\"query\":\"test\"}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_assistant_uses_block_input_when_no_deltas() {
|
||||
let (_, events) = run_dispatch(serde_json::json!({
|
||||
"type": "assistant",
|
||||
"message": { "content": [
|
||||
{ "type": "tool_use", "id": "tu_x", "name": "create_note", "input": { "title": "Hello", "content": "world" } }
|
||||
] }
|
||||
}));
|
||||
assert!(
|
||||
matches!(&events[0], ClaudeStreamEvent::ToolStart { input: Some(inp), .. }
|
||||
if inp.contains("title") && inp.contains("Hello"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_block_stop_clears_current_tool() {
|
||||
let (state, _) = run_dispatch_sequence(vec![
|
||||
serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_start", "content_block": { "type": "tool_use", "id": "t1", "name": "x", "input": {} } }
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "stream_event",
|
||||
"event": { "type": "content_block_stop" }
|
||||
}),
|
||||
]);
|
||||
assert!(state.current_tool_id.is_none());
|
||||
}
|
||||
|
||||
// --- run_claude_subprocess with mock scripts ---
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -584,7 +822,7 @@ mod tests {
|
||||
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));
|
||||
let result = run_claude_subprocess(&path, args, None, &mut |e| events.push(e));
|
||||
(result, events)
|
||||
}
|
||||
|
||||
@@ -735,6 +973,8 @@ mod tests {
|
||||
assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
|
||||
assert!(args.contains(&"--no-session-persistence".to_string()));
|
||||
assert!(!args.contains(&"--append-system-prompt".to_string()));
|
||||
// Native tools must NOT be disabled
|
||||
assert!(!args.contains(&"--tools".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,7 +1046,7 @@ mod tests {
|
||||
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));
|
||||
let result = run_claude_subprocess(&fake_bin, &[], None, &mut |e| events.push(e));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Failed to spawn"));
|
||||
}
|
||||
|
||||
661
src-tauri/src/commands.rs
Normal file
@@ -0,0 +1,661 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use crate::claude_cli::{
|
||||
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
|
||||
};
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use crate::indexing::{IndexStatus, IndexingProgress};
|
||||
use crate::search::SearchResponse;
|
||||
use crate::settings::Settings;
|
||||
use crate::theme::{ThemeFile, VaultSettings};
|
||||
use crate::vault::{RenameResult, VaultEntry};
|
||||
use crate::vault_config::VaultConfig;
|
||||
use crate::vault_list::VaultList;
|
||||
use crate::{
|
||||
frontmatter, git, github, indexing, menu, search, theme, vault, vault_config, vault_list,
|
||||
};
|
||||
|
||||
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
|
||||
/// Returns the original string unchanged if it doesn't start with `~` or if the
|
||||
/// home directory cannot be determined.
|
||||
pub fn expand_tilde(path: &str) -> Cow<'_, str> {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(home.to_string_lossy().into_owned());
|
||||
}
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
|
||||
}
|
||||
}
|
||||
Cow::Borrowed(path)
|
||||
}
|
||||
|
||||
pub 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(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit_unavailable(app_handle: &tauri::AppHandle) {
|
||||
use tauri::Emitter;
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "unavailable".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: true,
|
||||
error: Some("qmd not available".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vault commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_note_content(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::get_note_content(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_note_content(path: String, content: String) -> Result<(), String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::save_note_content(&path, &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_note(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
new_title: String,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let old_path = expand_tilde(&old_path);
|
||||
vault::rename_note(&vault_path, &old_path, &new_title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_note(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::delete_note(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::migrate_is_a_to_type(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
|
||||
let path = match target_path {
|
||||
Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(),
|
||||
_ => vault::default_vault_path()?.to_string_lossy().to_string(),
|
||||
};
|
||||
vault::create_getting_started_vault(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_vault_exists(path: String) -> bool {
|
||||
let path = expand_tilde(&path);
|
||||
vault::vault_exists(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_vault_path() -> Result<String, String> {
|
||||
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::save_image(&vault_path, &filename, &data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::copy_image_to_vault(&vault_path, &source_path)
|
||||
}
|
||||
|
||||
// ── Frontmatter commands ────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_frontmatter(
|
||||
path: String,
|
||||
key: String,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
frontmatter::update_frontmatter(&path, &key, value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
frontmatter::delete_frontmatter_property(&path, &key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(
|
||||
&path,
|
||||
"Trashed at",
|
||||
FrontmatterValue::String(now.clone()),
|
||||
)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// ── Git commands ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_history(&vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_modified_files(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_diff(&vault_path, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff_at_commit(
|
||||
vault_path: String,
|
||||
path: String,
|
||||
commit_hash: String,
|
||||
) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: String,
|
||||
limit: Option<usize>,
|
||||
skip: Option<usize>,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let limit = limit.unwrap_or(20);
|
||||
let skip = skip.unwrap_or(0);
|
||||
git::get_vault_pulse(&vault_path, limit, skip)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_commit(&vault_path, &message)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_last_commit_info(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_pull(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_conflict_files(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_mode(vault_path: String) -> String {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::get_conflict_mode(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub 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]
|
||||
pub 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]
|
||||
pub fn git_push(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
}
|
||||
|
||||
// ── GitHub commands ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
github::github_list_repos(&token).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_create_repo(
|
||||
token: String,
|
||||
name: String,
|
||||
private: bool,
|
||||
) -> Result<GithubRepo, String> {
|
||||
github::github_create_repo(&token, &name, private).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
github::clone_repo(&url, &token, &local_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
|
||||
github::github_device_flow_start().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
|
||||
github::github_device_flow_poll(&device_code).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
github::github_get_user(&token).await
|
||||
}
|
||||
|
||||
// ── AI / Claude commands ────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
|
||||
crate::ai_chat::send_chat(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_claude_cli() -> ClaudeCliStatus {
|
||||
crate::claude_cli::check_cli()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_chat(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: ChatStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::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]
|
||||
pub async fn stream_claude_agent(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
use tauri::Emitter;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
|
||||
let _ = app_handle.emit("claude-agent-stream", &event);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?
|
||||
}
|
||||
|
||||
// ── Search & indexing commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_vault(
|
||||
vault_path: String,
|
||||
query: String,
|
||||
mode: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
let limit = limit.unwrap_or(20);
|
||||
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
|
||||
.await
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_index_status(vault_path: String) -> IndexStatus {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
indexing::check_index_status(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_indexing(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: String,
|
||||
) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if indexing::find_qmd_binary().is_none() {
|
||||
log::info!("qmd binary not found — attempting auto-install via bun");
|
||||
let _ = app_handle.emit(
|
||||
"indexing-progress",
|
||||
IndexingProgress {
|
||||
phase: "installing".to_string(),
|
||||
current: 0,
|
||||
total: 0,
|
||||
done: false,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
match indexing::try_auto_install_qmd() {
|
||||
Ok(()) if indexing::find_qmd_binary().is_some() => {
|
||||
log::info!("qmd auto-installed successfully, proceeding with indexing");
|
||||
}
|
||||
Ok(()) => {
|
||||
log::warn!("qmd auto-install reported success but binary still not found");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err("qmd not available after install".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
log::info!("qmd auto-install failed: {e}");
|
||||
emit_unavailable(&app_handle);
|
||||
return Err(format!("qmd not available: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indexing::run_full_index(&vault_path, |progress| {
|
||||
let _ = app_handle.emit("indexing-progress", &progress);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Indexing task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Incremental index failed: {e}"))?
|
||||
}
|
||||
|
||||
// ── MCP commands ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::mcp::register_mcp(&vault_path))
|
||||
.await
|
||||
.map_err(|e| format!("Registration task failed: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
|
||||
tokio::task::spawn_blocking(crate::mcp::check_mcp_status)
|
||||
.await
|
||||
.map_err(|e| format!("MCP status check failed: {e}"))
|
||||
}
|
||||
|
||||
// ── Theme commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::list_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::get_theme(&vault_path, &theme_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::get_vault_settings(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::save_vault_settings(&vault_path, settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::set_active_theme(&vault_path, theme_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::create_theme(&vault_path, source_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::create_vault_theme(&vault_path, name.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::ensure_vault_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
theme::restore_default_themes(&vault_path)
|
||||
}
|
||||
|
||||
// ── Settings & config commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_build_number(app_handle: tauri::AppHandle) -> String {
|
||||
let version = app_handle.package_info().version.to_string();
|
||||
parse_build_label(&version)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_menu_state(
|
||||
app_handle: tauri::AppHandle,
|
||||
has_active_note: bool,
|
||||
has_modified_files: Option<bool>,
|
||||
has_conflicts: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
menu::set_note_items_enabled(&app_handle, has_active_note);
|
||||
if let Some(v) = has_modified_files {
|
||||
menu::set_git_commit_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = has_conflicts {
|
||||
menu::set_git_conflict_items_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_settings() -> Result<Settings, String> {
|
||||
crate::settings::get_settings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
crate::settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_vault_list() -> Result<VaultList, String> {
|
||||
vault_list::load_vault_list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_vault_list(list: VaultList) -> Result<(), String> {
|
||||
vault_list::save_vault_list(&list)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault_config::get_vault_config(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault_config::save_vault_config(&vault_path, config)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_with_subpath() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let result = expand_tilde("~/Documents/vault");
|
||||
assert_eq!(result, format!("{}/Documents/vault", home.display()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_alone() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let result = expand_tilde("~");
|
||||
assert_eq!(result, home.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_noop_for_absolute_path() {
|
||||
let result = expand_tilde("/usr/local/bin");
|
||||
assert_eq!(result, "/usr/local/bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_noop_for_relative_path() {
|
||||
let result = expand_tilde("some/relative/path");
|
||||
assert_eq!(result, "some/relative/path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_noop_for_tilde_in_middle() {
|
||||
let result = expand_tilde("/home/~user/path");
|
||||
assert_eq!(result, "/home/~user/path");
|
||||
}
|
||||
|
||||
#[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?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_archive_notes() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let note = dir.path().join("note.md");
|
||||
std::fs::write(¬e, "---\nStatus: Active\n---\n# Note\n").unwrap();
|
||||
|
||||
let result = batch_archive_notes(vec![note.to_str().unwrap().to_string()]);
|
||||
assert_eq!(result.unwrap(), 1);
|
||||
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("Archived: true"));
|
||||
assert!(content.contains("Status: Active"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_trash_notes() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let note = dir.path().join("note.md");
|
||||
std::fs::write(¬e, "---\nStatus: Active\n---\n# Note\n").unwrap();
|
||||
|
||||
let result = batch_trash_notes(vec![note.to_str().unwrap().to_string()]);
|
||||
assert_eq!(result.unwrap(), 1);
|
||||
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("Trashed: true"));
|
||||
assert!(content.contains("Trashed at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_vault_exists_false() {
|
||||
assert!(!check_vault_exists("/nonexistent/path/abc123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_default_vault_path_returns_ok() {
|
||||
let result = get_default_vault_path();
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,827 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Value type for frontmatter updates
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum FrontmatterValue {
|
||||
String(String),
|
||||
Number(f64),
|
||||
Bool(bool),
|
||||
List(Vec<String>),
|
||||
Null,
|
||||
}
|
||||
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_yaml_special_chars(s: &str) -> bool {
|
||||
s.contains(':') || s.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
fn starts_as_yaml_collection(s: &str) -> bool {
|
||||
s.starts_with('[') || s.starts_with('{')
|
||||
}
|
||||
|
||||
/// Check whether a YAML string value needs quoting to avoid ambiguity.
|
||||
fn needs_yaml_quoting(s: &str) -> bool {
|
||||
has_yaml_special_chars(s)
|
||||
|| starts_as_yaml_collection(s)
|
||||
|| matches!(s, "true" | "false" | "null")
|
||||
|| s.parse::<f64>().is_ok()
|
||||
}
|
||||
|
||||
/// Quote a string value for YAML, escaping internal double quotes.
|
||||
fn quote_yaml_string(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\"', "\\\""))
|
||||
}
|
||||
|
||||
/// Format a single YAML list item as ` - "value"`.
|
||||
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 {
|
||||
format!("{}", n as i64)
|
||||
} else {
|
||||
format!("{}", n)
|
||||
}
|
||||
}
|
||||
|
||||
impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if s.contains('\n') {
|
||||
format_block_scalar(s)
|
||||
} else if needs_yaml_quoting(s) {
|
||||
quote_yaml_string(s)
|
||||
} else {
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
FrontmatterValue::Number(n) => format_yaml_number(*n),
|
||||
FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
|
||||
FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(),
|
||||
FrontmatterValue::List(items) => items
|
||||
.iter()
|
||||
.map(|item| format_list_item(item))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
FrontmatterValue::Null => "null".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.).
|
||||
fn needs_key_quoting(key: &str) -> bool {
|
||||
key.chars()
|
||||
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
|
||||
}
|
||||
|
||||
/// Format a key for YAML output (quote if necessary)
|
||||
pub fn format_yaml_key(key: &str) -> String {
|
||||
if needs_key_quoting(key) {
|
||||
format!("\"{}\"", key)
|
||||
} else {
|
||||
key.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a line defines a specific key (handles quoted and unquoted keys)
|
||||
fn line_is_key(line: &str, key: &str) -> bool {
|
||||
let trimmed = line.trim_start();
|
||||
|
||||
if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dq = format!("\"{}\":", key);
|
||||
if trimmed.starts_with(&dq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let sq = format!("'{}\':", key);
|
||||
if trimmed.starts_with(&sq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Format a key-value pair as one or more YAML lines.
|
||||
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.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 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 `---`.
|
||||
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
|
||||
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
|
||||
let after_open = &content[4..];
|
||||
// Handle empty frontmatter: closing --- immediately after opening ---\n
|
||||
if let Some(stripped) = after_open.strip_prefix("---") {
|
||||
return Ok(("", stripped));
|
||||
}
|
||||
let fm_end = after_open
|
||||
.find("\n---")
|
||||
.map(|i| i + 4)
|
||||
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
|
||||
Ok((&content[4..fm_end], &content[fm_end + 4..]))
|
||||
}
|
||||
|
||||
/// Wrap content in a new frontmatter block containing a single field.
|
||||
fn prepend_new_frontmatter(content: &str, key: &str, value: &FrontmatterValue) -> String {
|
||||
let field_lines = format_yaml_field(key, value);
|
||||
format!("---\n{}\n---\n{}", field_lines.join("\n"), content)
|
||||
}
|
||||
|
||||
/// Apply a field update to existing frontmatter lines.
|
||||
/// Replaces the matching key (and its list continuations) with the new value,
|
||||
/// or appends if the key is not found. If `value` is None, removes the key.
|
||||
fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue>) -> Vec<String> {
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut found_key = false;
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
if !line_is_key(lines[i], key) {
|
||||
new_lines.push(lines[i].to_string());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// 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)
|
||||
if let Some(v) = value {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
}
|
||||
|
||||
if let (false, Some(v)) = (found_key, value) {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
|
||||
new_lines
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
pub fn update_frontmatter_content(
|
||||
content: &str,
|
||||
key: &str,
|
||||
value: Option<FrontmatterValue>,
|
||||
) -> Result<String, String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return match value {
|
||||
Some(v) => Ok(prepend_new_frontmatter(content, key, &v)),
|
||||
None => Ok(content.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let (fm_content, rest) = split_frontmatter(content)?;
|
||||
let lines: Vec<&str> = fm_content.lines().collect();
|
||||
let new_lines = apply_field_update(&lines, key, value.as_ref());
|
||||
let new_fm = new_lines.join("\n");
|
||||
Ok(format!("---\n{}\n---{}", new_fm, rest))
|
||||
}
|
||||
|
||||
/// Helper to read a file, apply a frontmatter transformation, and write back.
|
||||
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<String, String>,
|
||||
{
|
||||
let file_path = Path::new(path);
|
||||
if !file_path.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
||||
|
||||
let updated = transform(&content)?;
|
||||
|
||||
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Update a single frontmatter property in a markdown file.
|
||||
pub fn update_frontmatter(
|
||||
path: &str,
|
||||
key: &str,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, Some(value.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a frontmatter property from a markdown file.
|
||||
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, None)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Status: Active"));
|
||||
assert!(!updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_add_new_key() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Owner: Luca"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_quoted_key() {
|
||||
let content = "---\n\"Is A\": Note\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Is A",
|
||||
Some(FrontmatterValue::String("Project".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("\"Is A\": Project"));
|
||||
assert!(!updated.contains("Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"Alias1".to_string(),
|
||||
"Alias2".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("aliases:"));
|
||||
assert!(updated.contains(" - \"Alias1\""));
|
||||
assert!(updated.contains(" - \"Alias2\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_replace_list() {
|
||||
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec!["New1".to_string()])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" - \"New1\""));
|
||||
assert!(!updated.contains("Old1"));
|
||||
assert!(!updated.contains("Old2"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_property() {
|
||||
let content = "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "Owner", None).unwrap();
|
||||
assert!(!updated.contains("Owner"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_list_property() {
|
||||
let content = "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "aliases", None).unwrap();
|
||||
assert!(!updated.contains("aliases"));
|
||||
assert!(!updated.contains("Alias1"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_existing() {
|
||||
let content = "# Test\n\nSome content here.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Draft".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.starts_with("---\n"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
assert!(updated.contains("# Test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_bool() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Reviewed: true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_simple() {
|
||||
assert_eq!(format_yaml_key("Status"), "Status");
|
||||
assert_eq!(format_yaml_key("is_a"), "is_a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_spaces() {
|
||||
assert_eq!(format_yaml_key("Is A"), "\"Is A\"");
|
||||
assert_eq!(format_yaml_key("Created at"), "\"Created at\"");
|
||||
}
|
||||
|
||||
// --- to_yaml_value quoting tests ---
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_colon() {
|
||||
let v = FrontmatterValue::String("key: value".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"key: value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_hash() {
|
||||
let v = FrontmatterValue::String("has # comment".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"has # comment\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_bracket() {
|
||||
let v = FrontmatterValue::String("[array-like]".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"[array-like]\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_brace() {
|
||||
let v = FrontmatterValue::String("{object-like}".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "\"{object-like}\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_bool_like() {
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("true".to_string()).to_yaml_value(),
|
||||
"\"true\""
|
||||
);
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("false".to_string()).to_yaml_value(),
|
||||
"\"false\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_null_like() {
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("null".to_string()).to_yaml_value(),
|
||||
"\"null\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_needs_quoting_number_like() {
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("42".to_string()).to_yaml_value(),
|
||||
"\"42\""
|
||||
);
|
||||
assert_eq!(
|
||||
FrontmatterValue::String("3.14".to_string()).to_yaml_value(),
|
||||
"\"3.14\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_string_plain() {
|
||||
let v = FrontmatterValue::String("Hello World".to_string());
|
||||
assert_eq!(v.to_yaml_value(), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_integer() {
|
||||
let v = FrontmatterValue::Number(42.0);
|
||||
assert_eq!(v.to_yaml_value(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_float() {
|
||||
let v = FrontmatterValue::Number(3.14);
|
||||
assert_eq!(v.to_yaml_value(), "3.14");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_null() {
|
||||
assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_empty_list() {
|
||||
let v = FrontmatterValue::List(vec![]);
|
||||
assert_eq!(v.to_yaml_value(), "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_list_with_colon() {
|
||||
let v = FrontmatterValue::List(vec!["key: value".to_string()]);
|
||||
assert_eq!(v.to_yaml_value(), " - \"key: value\"");
|
||||
}
|
||||
|
||||
// --- update_frontmatter_content additional type tests ---
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Priority: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number_float() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Score: 9.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_null() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
|
||||
assert!(updated.contains("ClearMe: null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
|
||||
.unwrap();
|
||||
assert!(updated.contains("tags: []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_malformed_no_closing_fence() {
|
||||
let content = "---\nStatus: Draft\nNo closing fence here";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Malformed frontmatter"));
|
||||
}
|
||||
|
||||
// --- delete non-existent key (should be no-op) ---
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_key_noop() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_from_no_frontmatter_noop() {
|
||||
let content = "# Test\n\nSome content.";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
// --- line_is_key tests ---
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_unquoted() {
|
||||
assert!(line_is_key("Status: Draft", "Status"));
|
||||
assert!(!line_is_key("Status: Draft", "Owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_double_quoted() {
|
||||
assert!(line_is_key("\"Is A\": Note", "Is A"));
|
||||
assert!(!line_is_key("\"Is A\": Note", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_single_quoted() {
|
||||
assert!(line_is_key("'Is A': Note", "Is A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_leading_whitespace() {
|
||||
assert!(line_is_key(" Status: Draft", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_partial_match() {
|
||||
// "StatusBar" should not match key "Status"
|
||||
assert!(!line_is_key("StatusBar: value", "Status"));
|
||||
}
|
||||
|
||||
// --- with_frontmatter error cases ---
|
||||
|
||||
#[test]
|
||||
fn test_with_frontmatter_file_not_found() {
|
||||
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
// --- roundtrip tests ---
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".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 {
|
||||
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"A".to_string(),
|
||||
"B".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
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 aliases = map.get("aliases").unwrap();
|
||||
if let gray_matter::Pod::Array(arr) = aliases {
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].as_string().unwrap(), "A");
|
||||
assert_eq!(arr[1].as_string().unwrap(), "B");
|
||||
} else {
|
||||
panic!("Expected array");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_add_then_delete() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let with_owner = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(with_owner.contains("Owner: Luca"));
|
||||
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
|
||||
assert!(!without_owner.contains("Owner"));
|
||||
assert!(without_owner.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
// --- format_yaml_key additional tests ---
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_colon() {
|
||||
assert_eq!(format_yaml_key("key:value"), "\"key:value\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_hash() {
|
||||
assert_eq!(format_yaml_key("has#tag"), "\"has#tag\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_key_with_period() {
|
||||
assert_eq!(format_yaml_key("key.name"), "\"key.name\"");
|
||||
}
|
||||
|
||||
// --- split_frontmatter / empty frontmatter edge cases ---
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block() {
|
||||
// ---\n---\n (no fields between opening and closing ---)
|
||||
let result = split_frontmatter("---\n---\n");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"split_frontmatter should handle empty frontmatter block"
|
||||
);
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert_eq!(rest, "\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_no_trailing_newline() {
|
||||
// ---\n--- (no trailing newline)
|
||||
let result = split_frontmatter("---\n---");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"split_frontmatter should handle empty frontmatter without trailing newline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_with_body() {
|
||||
// ---\n---\n\n# Title\n
|
||||
let result = split_frontmatter("---\n---\n\n# Title\n");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"split_frontmatter should handle empty frontmatter with body"
|
||||
);
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert!(rest.contains("# Title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_block() {
|
||||
let content = "---\n---\n\n# Test\n";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New Title".to_string())),
|
||||
);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"update_frontmatter_content should handle empty frontmatter block"
|
||||
);
|
||||
let updated = result.unwrap();
|
||||
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 ---
|
||||
let content = "---\ntitle: Old\n---\n";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New".to_string())),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let updated = result.unwrap();
|
||||
assert!(updated.contains("title: New"));
|
||||
assert!(!updated.contains("title: Old"));
|
||||
}
|
||||
}
|
||||
207
src-tauri/src/frontmatter/mod.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
mod ops;
|
||||
mod yaml;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub use ops::update_frontmatter_content;
|
||||
pub use yaml::{format_yaml_key, FrontmatterValue};
|
||||
|
||||
/// Helper to read a file, apply a frontmatter transformation, and write back.
|
||||
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
|
||||
where
|
||||
F: FnOnce(&str) -> Result<String, String>,
|
||||
{
|
||||
let file_path = Path::new(path);
|
||||
if !file_path.exists() {
|
||||
return Err(format!("File does not exist: {}", path));
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
||||
|
||||
let updated = transform(&content)?;
|
||||
|
||||
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Update a single frontmatter property in a markdown file.
|
||||
pub fn update_frontmatter(
|
||||
path: &str,
|
||||
key: &str,
|
||||
value: FrontmatterValue,
|
||||
) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, Some(value.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete a frontmatter property from a markdown file.
|
||||
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
|
||||
with_frontmatter(path, |content| {
|
||||
update_frontmatter_content(content, key, None)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_with_frontmatter_file_not_found() {
|
||||
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
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 {
|
||||
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_update_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"A".to_string(),
|
||||
"B".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
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 aliases = map.get("aliases").unwrap();
|
||||
if let gray_matter::Pod::Array(arr) = aliases {
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].as_string().unwrap(), "A");
|
||||
assert_eq!(arr[1].as_string().unwrap(), "B");
|
||||
} else {
|
||||
panic!("Expected array");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_add_then_delete() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let with_owner = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(with_owner.contains("Owner: Luca"));
|
||||
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
|
||||
assert!(!without_owner.contains("Owner"));
|
||||
assert!(without_owner.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_block() {
|
||||
let content = "---\n---\n\n# Test\n";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New Title".to_string())),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().contains("title: New Title"));
|
||||
}
|
||||
|
||||
#[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("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 updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String("## New\n\n## Content".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" ## New"));
|
||||
assert!(!updated.contains("## Old"));
|
||||
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("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_body_after_closing() {
|
||||
let content = "---\ntitle: Old\n---\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"title",
|
||||
Some(FrontmatterValue::String("New".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("title: New"));
|
||||
assert!(!updated.contains("title: Old"));
|
||||
}
|
||||
|
||||
#[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();
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
331
src-tauri/src/frontmatter/ops.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
use super::yaml::{format_yaml_field, FrontmatterValue};
|
||||
|
||||
/// Check if a line defines a specific key (handles quoted and unquoted keys)
|
||||
fn line_is_key(line: &str, key: &str) -> bool {
|
||||
let trimmed = line.trim_start();
|
||||
|
||||
if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') {
|
||||
return true;
|
||||
}
|
||||
|
||||
let dq = format!("\"{}\":", key);
|
||||
if trimmed.starts_with(&dq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let sq = format!("'{}\':", key);
|
||||
if trimmed.starts_with(&sq) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 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 `---`.
|
||||
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
|
||||
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
|
||||
let after_open = &content[4..];
|
||||
// Handle empty frontmatter: closing --- immediately after opening ---\n
|
||||
if let Some(stripped) = after_open.strip_prefix("---") {
|
||||
return Ok(("", stripped));
|
||||
}
|
||||
let fm_end = after_open
|
||||
.find("\n---")
|
||||
.map(|i| i + 4)
|
||||
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
|
||||
Ok((&content[4..fm_end], &content[fm_end + 4..]))
|
||||
}
|
||||
|
||||
/// Wrap content in a new frontmatter block containing a single field.
|
||||
fn prepend_new_frontmatter(content: &str, key: &str, value: &FrontmatterValue) -> String {
|
||||
let field_lines = format_yaml_field(key, value);
|
||||
format!("---\n{}\n---\n{}", field_lines.join("\n"), content)
|
||||
}
|
||||
|
||||
/// Apply a field update to existing frontmatter lines.
|
||||
/// Replaces the matching key (and its list continuations) with the new value,
|
||||
/// or appends if the key is not found. If `value` is None, removes the key.
|
||||
fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue>) -> Vec<String> {
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
let mut found_key = false;
|
||||
let mut i = 0;
|
||||
|
||||
while i < lines.len() {
|
||||
if !line_is_key(lines[i], key) {
|
||||
new_lines.push(lines[i].to_string());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// 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)
|
||||
if let Some(v) = value {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
}
|
||||
|
||||
if let (false, Some(v)) = (found_key, value) {
|
||||
new_lines.extend(format_yaml_field(key, v));
|
||||
}
|
||||
|
||||
new_lines
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
pub fn update_frontmatter_content(
|
||||
content: &str,
|
||||
key: &str,
|
||||
value: Option<FrontmatterValue>,
|
||||
) -> Result<String, String> {
|
||||
if !content.starts_with("---\n") {
|
||||
return match value {
|
||||
Some(v) => Ok(prepend_new_frontmatter(content, key, &v)),
|
||||
None => Ok(content.to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
let (fm_content, rest) = split_frontmatter(content)?;
|
||||
let lines: Vec<&str> = fm_content.lines().collect();
|
||||
let new_lines = apply_field_update(&lines, key, value.as_ref());
|
||||
let new_fm = new_lines.join("\n");
|
||||
Ok(format!("---\n{}\n---{}", new_fm, rest))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_string() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Status: Active"));
|
||||
assert!(!updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_add_new_key() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Owner",
|
||||
Some(FrontmatterValue::String("Luca".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("Owner: Luca"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_quoted_key() {
|
||||
let content = "---\n\"Is A\": Note\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Is A",
|
||||
Some(FrontmatterValue::String("Project".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("\"Is A\": Project"));
|
||||
assert!(!updated.contains("Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec![
|
||||
"Alias1".to_string(),
|
||||
"Alias2".to_string(),
|
||||
])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("aliases:"));
|
||||
assert!(updated.contains(" - \"Alias1\""));
|
||||
assert!(updated.contains(" - \"Alias2\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_replace_list() {
|
||||
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"aliases",
|
||||
Some(FrontmatterValue::List(vec!["New1".to_string()])),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" - \"New1\""));
|
||||
assert!(!updated.contains("Old1"));
|
||||
assert!(!updated.contains("Old2"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_property() {
|
||||
let content = "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "Owner", None).unwrap();
|
||||
assert!(!updated.contains("Owner"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_list_property() {
|
||||
let content = "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "aliases", None).unwrap();
|
||||
assert!(!updated.contains("aliases"));
|
||||
assert!(!updated.contains("Alias1"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_existing() {
|
||||
let content = "# Test\n\nSome content here.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Draft".to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.starts_with("---\n"));
|
||||
assert!(updated.contains("Status: Draft"));
|
||||
assert!(updated.contains("# Test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_bool() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Reviewed: true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Priority: 5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_number_float() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
|
||||
.unwrap();
|
||||
assert!(updated.contains("Score: 9.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_null() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
|
||||
assert!(updated.contains("ClearMe: null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_empty_list() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated =
|
||||
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
|
||||
.unwrap();
|
||||
assert!(updated.contains("tags: []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_malformed_no_closing_fence() {
|
||||
let content = "---\nStatus: Draft\nNo closing fence here";
|
||||
let result = update_frontmatter_content(
|
||||
content,
|
||||
"Status",
|
||||
Some(FrontmatterValue::String("Active".to_string())),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Malformed frontmatter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_key_noop() {
|
||||
let content = "---\nStatus: Draft\n---\n# Test\n";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_from_no_frontmatter_noop() {
|
||||
let content = "# Test\n\nSome content.";
|
||||
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
|
||||
assert_eq!(updated, content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_unquoted() {
|
||||
assert!(line_is_key("Status: Draft", "Status"));
|
||||
assert!(!line_is_key("Status: Draft", "Owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_double_quoted() {
|
||||
assert!(line_is_key("\"Is A\": Note", "Is A"));
|
||||
assert!(!line_is_key("\"Is A\": Note", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_single_quoted() {
|
||||
assert!(line_is_key("'Is A': Note", "Is A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_leading_whitespace() {
|
||||
assert!(line_is_key(" Status: Draft", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_is_key_partial_match() {
|
||||
assert!(!line_is_key("StatusBar: value", "Status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block() {
|
||||
let result = split_frontmatter("---\n---\n");
|
||||
assert!(result.is_ok());
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert_eq!(rest, "\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_no_trailing_newline() {
|
||||
let result = split_frontmatter("---\n---");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_frontmatter_empty_block_with_body() {
|
||||
let result = split_frontmatter("---\n---\n\n# Title\n");
|
||||
assert!(result.is_ok());
|
||||
let (fm, rest) = result.unwrap();
|
||||
assert_eq!(fm, "");
|
||||
assert!(rest.contains("# Title"));
|
||||
}
|
||||
}
|
||||