Compare commits
172 Commits
v0.2026030
...
v0.2026031
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28fa9673b7 | ||
|
|
18e173faca | ||
|
|
a16b477878 | ||
|
|
7bcbf87067 | ||
|
|
a23264eacb | ||
|
|
18b65f1e59 | ||
|
|
52d68aa506 | ||
|
|
8cb2194842 | ||
|
|
66090688f9 | ||
|
|
a15f36ec6a | ||
|
|
8137125569 | ||
|
|
9891a29f7f | ||
|
|
6c9b39c0f0 | ||
|
|
3207b0b10e | ||
|
|
088c495520 | ||
|
|
70f984c399 | ||
|
|
0d6cce1588 | ||
|
|
73278f3baf | ||
|
|
93dc454a8a | ||
|
|
1b7b7f3fde | ||
|
|
7e20a36469 | ||
|
|
61a145c49b | ||
|
|
9a7369c799 | ||
|
|
13d3b2d375 | ||
|
|
9994f2386c | ||
|
|
a496a115bf | ||
|
|
ed4926d59f | ||
|
|
c158cbccff | ||
|
|
12416b99bc | ||
|
|
a25f9ee1fc | ||
|
|
8a48c21445 | ||
|
|
7c72494efb | ||
|
|
10b4e6d038 | ||
|
|
55519e53ad | ||
|
|
fafa4e394b | ||
|
|
ab02aa5e96 | ||
|
|
4dd27cf0c3 | ||
|
|
c499ef30f0 | ||
|
|
b3bf2bf76e | ||
|
|
13622bc236 | ||
|
|
80ad5cfad7 | ||
|
|
068d70c264 | ||
|
|
86ffb43eb7 | ||
|
|
3504bb221a | ||
|
|
f2136f17ef | ||
|
|
75ca18d4d0 | ||
|
|
47c408dc50 | ||
|
|
96d7df368a | ||
|
|
c7b0c15537 | ||
|
|
0ee6e76d10 | ||
|
|
25c44910d1 | ||
|
|
61760c4a41 | ||
|
|
8104a8380c | ||
|
|
593a0d3d54 | ||
|
|
a50aae70e8 | ||
|
|
2387b9a637 | ||
|
|
27452515d7 | ||
|
|
14a4d371e6 | ||
|
|
9199ceaa35 | ||
|
|
6af18655de | ||
|
|
90bf73524c | ||
|
|
c1f7f7ec6f | ||
|
|
d1021b9131 | ||
|
|
b9139e2d57 | ||
|
|
c692c5d067 | ||
|
|
b86f6d5b88 | ||
|
|
3426cbc882 | ||
|
|
57ff0f18f8 | ||
|
|
abf6b51369 | ||
|
|
02c784b286 | ||
|
|
fc4ba24c4e | ||
|
|
eaeb6e5d40 | ||
|
|
64fe0f1c25 | ||
|
|
474353718a | ||
|
|
a933e6a787 | ||
|
|
79689839b2 | ||
|
|
b10e9facad | ||
|
|
2a32d2b5ad | ||
|
|
11a4e1593b | ||
|
|
080e5ff62d | ||
|
|
60fd4d9ade | ||
|
|
3583cb9518 | ||
|
|
1f497e4b18 | ||
|
|
13b325217b | ||
|
|
1714da402e | ||
|
|
7b75cb79c4 | ||
|
|
a66eedbecd | ||
|
|
dc92fd1f57 | ||
|
|
4012a65a73 | ||
|
|
b2da813923 | ||
|
|
e461a91721 | ||
|
|
edf24898ae | ||
|
|
1c8542bd01 | ||
|
|
aef98f17eb | ||
|
|
5c85bc41f6 | ||
|
|
60f3139b3e | ||
|
|
e40c09a2ef | ||
|
|
f90f703096 | ||
|
|
eaf31ff61c | ||
|
|
db50f779c9 | ||
|
|
e228bd3a52 | ||
|
|
3a0fd0620f | ||
|
|
e2489b8957 | ||
|
|
11f8731d32 | ||
|
|
6f7a7d71d8 | ||
|
|
aef1924407 | ||
|
|
2173df6f0d | ||
|
|
98cad76aa0 | ||
|
|
f076c71cc1 | ||
|
|
44221e50d4 | ||
|
|
b46c71c76f | ||
|
|
06c01539af | ||
|
|
fddc323d1e | ||
|
|
23b63bb583 | ||
|
|
c858cf8d3b | ||
|
|
0dc684453d | ||
|
|
aafe69b573 | ||
|
|
a3c53c19d1 | ||
|
|
5185f363e6 | ||
|
|
3915363026 | ||
|
|
2249c4a450 | ||
|
|
6575ec2d1c | ||
|
|
1f08694e9d | ||
|
|
a99cb2af78 | ||
|
|
8eabcd9467 | ||
|
|
97112b9c84 | ||
|
|
a53819e56a | ||
|
|
41a2d25311 | ||
|
|
3a3d0bbcdf | ||
|
|
a4468289a2 | ||
|
|
bcfd37d481 | ||
|
|
f27ebe05c4 | ||
|
|
7470e4f4a7 | ||
|
|
0e503cb179 | ||
|
|
d83f04c6ff | ||
|
|
9ffa6930c5 | ||
|
|
88b20b83dc | ||
|
|
548e5694ac | ||
|
|
0cf8f55a8d | ||
|
|
72b88cef43 | ||
|
|
8db9f61d5c | ||
|
|
fb2067ec79 | ||
|
|
b60bdb685d | ||
|
|
83009d8fb9 | ||
|
|
068d434344 | ||
|
|
6b2a1b0659 | ||
|
|
bf5f5521af | ||
|
|
f4961d0bc3 | ||
|
|
058de96cbc | ||
|
|
d29f919182 | ||
|
|
42e37e035c | ||
|
|
0ad0fa9b6b | ||
|
|
c37c03d6a9 | ||
|
|
fcc264d7dc | ||
|
|
382ba0a6d4 | ||
|
|
4719810b10 | ||
|
|
20b4ba7a3b | ||
|
|
de00ab6794 | ||
|
|
5d8f514bea | ||
|
|
1fd3ea02ae | ||
|
|
8da1484ebf | ||
|
|
90ebc2e939 | ||
|
|
c14927df8f | ||
|
|
a6d60695a2 | ||
|
|
7ddc0c14bf | ||
|
|
2a00b8aac7 | ||
|
|
b7d2304282 | ||
|
|
50b5fa9c2e | ||
|
|
963e7cf111 | ||
|
|
c9a5d20c12 | ||
|
|
586e1fcde5 | ||
|
|
75d67623ce |
@@ -1 +0,0 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5V3BxWUNBZU1LZWxOK3ZEZTZkdGhzM2l6cnpVUmIvUEtTTWgzLzNEU1VoZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQUFpN2xxclpGK3YzRERub1EvZFdsdVdORktuOHZYVlB0S2U2QkhNdlMreElkRVdabTh6UllNYzNFb2VWYTVCayszNUpyOC94Z0dJS2pVQ3NSKzdKNEszZHpDZll0aGdtV1J6bWFXODc4VWJpOVFYdUhEai9tNHQ2U3ZRbVd1NHBkR01YS2ZrUDlPRVU9Cg==
|
||||
@@ -1 +0,0 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVCQTVDRkIzNkFGRTYwQjUKUldTMVlQNXFzOCtsVzROZmJLR0JFVGw1a1UzKzViY3dUcWFoaUttRFhhVk8rVUhrc29QL1FPeXUK
|
||||
@@ -1 +0,0 @@
|
||||
33549
|
||||
5
.codesceneignore
Normal file
5
.codesceneignore
Normal file
@@ -0,0 +1,5 @@
|
||||
# Exclude third-party tools and their dependencies from CodeScene analysis
|
||||
tools/
|
||||
e2e/
|
||||
tests/
|
||||
scripts/
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -30,9 +30,6 @@ dist-ssr
|
||||
# Coverage reports
|
||||
/coverage/
|
||||
|
||||
# Laputa vault cache
|
||||
.laputa-cache.json
|
||||
|
||||
# Demo vault and helper scripts
|
||||
demo-vault/
|
||||
select_demo_notes*.py
|
||||
@@ -45,3 +42,28 @@ src-tauri/target
|
||||
|
||||
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
|
||||
src-tauri/resources/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Dev screenshots
|
||||
screenshots/
|
||||
|
||||
# Stale planning docs (keep locally if needed, not in repo)
|
||||
REDESIGN-PLAN.md
|
||||
SF-SYMBOLS-MIGRATION.md
|
||||
CODE-HEALTH-REPORT.md
|
||||
|
||||
# Local home dir artifact from worktree ops
|
||||
(HOME)/
|
||||
|
||||
# Runtime / process files
|
||||
.claude-pid
|
||||
|
||||
# Generated vault index files (qmd/search artifacts)
|
||||
.laputa-index.json
|
||||
|
||||
# Tauri signing keys (never commit private keys)
|
||||
*.key
|
||||
*.key.pub
|
||||
|
||||
@@ -104,26 +104,40 @@ else
|
||||
echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)"
|
||||
fi
|
||||
|
||||
# ── 5. CodeScene code health gate (≥9.2) ────────────────────────────────
|
||||
# ── 5. CodeScene code health gate ────────────────────────────────────────
|
||||
echo ""
|
||||
echo "🏥 [5/5] CodeScene code health gate (≥9.2)..."
|
||||
echo "🏥 [5/5] CodeScene code health gate (hotspot ≥9.2, average ≥8.8)..."
|
||||
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
|
||||
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
|
||||
else
|
||||
THRESHOLD=9.2
|
||||
SCORE=$(curl -sf \
|
||||
HOTSPOT_THRESHOLD=9.2
|
||||
AVERAGE_THRESHOLD=8.8
|
||||
API_RESPONSE=$(curl -sf \
|
||||
-H "Authorization: Bearer $CODESCENE_PAT" \
|
||||
-H "Accept: application/json" \
|
||||
"https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID" \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])")
|
||||
echo " Hotspot Code Health: $SCORE (threshold: $THRESHOLD)"
|
||||
"https://api.codescene.io/v2/projects/$CODESCENE_PROJECT_ID")
|
||||
HOTSPOT_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['hotspot_code_health']['now'])")
|
||||
AVERAGE_SCORE=$(echo "$API_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['analysis']['average_code_health']['now'])")
|
||||
echo " Hotspot Code Health: $HOTSPOT_SCORE (threshold: $HOTSPOT_THRESHOLD)"
|
||||
echo " Average Code Health: $AVERAGE_SCORE (threshold: $AVERAGE_THRESHOLD)"
|
||||
python3 -c "
|
||||
score = float('$SCORE')
|
||||
threshold = float('$THRESHOLD')
|
||||
if score < threshold:
|
||||
print(f' ❌ Code Health {score:.2f} below threshold {threshold}')
|
||||
hotspot = float('$HOTSPOT_SCORE')
|
||||
average = float('$AVERAGE_SCORE')
|
||||
ht = float('$HOTSPOT_THRESHOLD')
|
||||
at = float('$AVERAGE_THRESHOLD')
|
||||
failed = False
|
||||
if hotspot < ht:
|
||||
print(f' ❌ Hotspot Code Health {hotspot:.2f} below threshold {ht}')
|
||||
failed = True
|
||||
else:
|
||||
print(f' ✅ Hotspot Code Health {hotspot:.2f} ≥ {ht}')
|
||||
if average < at:
|
||||
print(f' ❌ Average Code Health {average:.2f} below threshold {at}')
|
||||
failed = True
|
||||
else:
|
||||
print(f' ✅ Average Code Health {average:.2f} ≥ {at}')
|
||||
if failed:
|
||||
exit(1)
|
||||
print(f' ✅ Code Health {score:.2f} ≥ {threshold}')
|
||||
"
|
||||
fi
|
||||
|
||||
|
||||
265
CLAUDE.md
265
CLAUDE.md
@@ -1,216 +1,84 @@
|
||||
# CLAUDE.md — Laputa App
|
||||
|
||||
## ⛔ BEFORE EVERY COMMIT — Non-negotiable checklist
|
||||
|
||||
Run all of these. If any fails, fix before committing. No exceptions.
|
||||
## ⛔ BEFORE EVERY COMMIT
|
||||
|
||||
```bash
|
||||
pnpm lint && npx tsc --noEmit # lint + types
|
||||
pnpm test # unit tests
|
||||
pnpm test:coverage # frontend ≥70% coverage
|
||||
cargo test # Rust tests
|
||||
pnpm lint && npx tsc --noEmit
|
||||
pnpm test
|
||||
pnpm test:coverage # frontend ≥70%
|
||||
cargo test
|
||||
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
|
||||
pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix structurally (see below)
|
||||
pre_commit_code_health_safeguard # CodeScene ≥9.2 hotspot + ≥8.8 average
|
||||
```
|
||||
|
||||
**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.
|
||||
If `pre_commit_code_health_safeguard` fails: extract hooks, split components, reduce complexity. Never add `// eslint-disable`, `#[allow(...)]`, or `as any` to pass the gate.
|
||||
|
||||
## ⛔ BEFORE FIRING laputa-task-done — Two-phase QA (mandatory)
|
||||
## ⛔ BEFORE FIRING laputa-task-done — Two-phase QA
|
||||
|
||||
### Phase 1: Playwright browser QA (headless, you do this yourself)
|
||||
### Phase 1: Playwright (you do this)
|
||||
|
||||
Test every acceptance criterion using Playwright against the dev server **before** marking done. This catches 80% of bugs before Brian sees them.
|
||||
Write a test in `tests/smoke/<slug>.spec.ts` that covers every acceptance criterion. The test must fail before your fix and pass after. Run it:
|
||||
|
||||
```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
|
||||
sleep 3
|
||||
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
|
||||
**If your task touches filesystem, git, AI, MCP, or any native Tauri command**: also test with `pnpm tauri dev` against `~/Laputa` (not demo vault). Use `osascript` keyboard events — no mouse, no `cliclick`.
|
||||
|
||||
**Playwright is non-negotiable even if tests pass.** Unit tests verify code; Playwright verifies the user experience in the real browser. Both are required.
|
||||
### Phase 2: Native QA (Brian does this after push)
|
||||
|
||||
> **⚠️ 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.
|
||||
Brian installs the release build and runs keyboard-only QA. Phase 1 must pass first or the task goes to To Rework.
|
||||
|
||||
### 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`
|
||||
3. Start app: `pnpm tauri dev` from worktree
|
||||
4. Switch vault to `~/Laputa` (not demo)
|
||||
5. Test the feature/fix with real mouse clicks (`cliclick`) on real notes
|
||||
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:
|
||||
Fire done signal only after Phase 1 passes:
|
||||
```bash
|
||||
rm -f /tmp/laputa-qa.lock
|
||||
openclaw system event --text "laputa-task-done:<task_id>:<slug>" --mode now
|
||||
```
|
||||
|
||||
## ⛔ CODE HEALTH — No shortcuts
|
||||
|
||||
If `pre_commit_code_health_safeguard` flags a file:
|
||||
- **Understand why** — use `code_health_review` via CodeScene MCP
|
||||
- Fix the structural problem (extract hooks, split components, reduce complexity)
|
||||
- **Never** add a JSDoc comment, `#[allow(...)]`, `// eslint-disable`, or `as any` just to pass the gate
|
||||
- It's fine to take longer. False quality is worse than no quality.
|
||||
|
||||
---
|
||||
|
||||
## Project
|
||||
|
||||
Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with YAML frontmatter.
|
||||
|
||||
- **Spec**: `docs/PROJECT-SPEC.md`
|
||||
- **Architecture**: `docs/ARCHITECTURE.md`
|
||||
- **Abstractions**: `docs/ABSTRACTIONS.md`
|
||||
- **Wireframes**: `ui-design.pen`
|
||||
- **Luca's vault**: `~/Laputa/` (~9200 markdown files)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Desktop: Tauri v2 (Rust backend)
|
||||
- Frontend: React 18 + TypeScript + BlockNote editor
|
||||
- Tests: Vitest (unit), Playwright (E2E), `cargo test` (Rust)
|
||||
- Package manager: pnpm
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src-tauri/src/` — Rust backend (file I/O, git, frontmatter parsing)
|
||||
- `src/` — React frontend
|
||||
- `src/mock-tauri.ts` — Mock layer for browser/test env (silently swallows Tauri calls — **not a substitute for native app testing**)
|
||||
- `src/types.ts` — Shared TypeScript types
|
||||
- **Spec**: `docs/PROJECT-SPEC.md` | **Architecture**: `docs/ARCHITECTURE.md` | **Abstractions**: `docs/ABSTRACTIONS.md`
|
||||
- **Wireframes**: `ui-design.pen` | **Luca's vault**: `~/Laputa/` (~9200 markdown files)
|
||||
- Stack: Rust backend, React + BlockNote editor, Vitest + Playwright + cargo test, pnpm
|
||||
|
||||
## How to Work
|
||||
|
||||
- **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
|
||||
- **Push directly to main** — no PRs ever. The pre-push hook runs all checks.
|
||||
- **⛔ NEVER open a PR** — branches diverge and cause rebase churn.
|
||||
- **⛔ NEVER use --no-verify**
|
||||
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||
|
||||
## TDD — Red/Green/Refactor (mandatory)
|
||||
## TDD (mandatory)
|
||||
|
||||
**Always use test-driven development.** No production code without a failing test first.
|
||||
Red → Green → Refactor → Commit. One cycle per commit. For bugs: write a failing regression test first, then fix. Exception: pure CSS/layout with no logic.
|
||||
|
||||
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.
|
||||
## ⛔ Docs — Keep docs/ in sync
|
||||
|
||||
**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
|
||||
After adding a Tauri command, new component/hook, data model change, or new integration: update `docs/ARCHITECTURE.md`, `docs/ABSTRACTIONS.md`, and/or `docs/GETTING-STARTED.md` in the same commit. Use Mermaid for diagrams (not ASCII). Exception: spatial wireframe layouts.
|
||||
|
||||
**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`
|
||||
## Design File (UI tasks)
|
||||
|
||||
**For Rust:**
|
||||
```bash
|
||||
cargo watch -x test # run tests on every save
|
||||
```
|
||||
1. Open `ui-design.pen` first — study existing frames for visual language.
|
||||
2. Design in light mode. Create `design/<slug>.pen` for the task.
|
||||
3. On merge to main: merge frames into `ui-design.pen`, delete `design/<slug>.pen`.
|
||||
|
||||
**For frontend:**
|
||||
```bash
|
||||
pnpm test --watch # run tests on every save
|
||||
```
|
||||
## Vault Retrocompatibility
|
||||
|
||||
**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.
|
||||
Every feature that depends on vault files must auto-bootstrap: check if file/folder exists on vault open, create with defaults if missing (silent, idempotent). Register with the central `Cmd+K → "Repair Vault"` command.
|
||||
|
||||
## Testing (quality bar)
|
||||
## Keyboard-First + Menu Bar (mandatory)
|
||||
|
||||
- Unit tests must cover real business logic, not "component renders"
|
||||
- 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)
|
||||
|
||||
Every task with UI changes needs a design file. Follow this process:
|
||||
|
||||
1. **Open `ui-design.pen` first** — study existing frames to understand the visual language, spacing, and component style before designing anything new.
|
||||
2. **Design in light mode** — all existing designs use light mode. New frames must match. Never use dark mode for designs.
|
||||
3. **Create `design/<slug>.pen`** for the new feature — additive only, NOT a copy of ui-design.pen.
|
||||
4. **When merging to main** — merge your frames into `ui-design.pen` with proper layout:
|
||||
- Place frames in a logical area (group by feature area, not stacked on top of each other)
|
||||
- Leave at least 100px spacing between frames
|
||||
- **Delete `design/<slug>.pen`** after merging — the frames now live in `ui-design.pen`
|
||||
|
||||
```bash
|
||||
mkdir -p design
|
||||
# Study schema first:
|
||||
node -e "const f=JSON.parse(require('fs').readFileSync('ui-design.pen','utf8')); console.log(JSON.stringify(f.children[0],null,2))"
|
||||
# Start fresh:
|
||||
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.
|
||||
Every feature must be reachable via keyboard. Every new command palette entry must also appear in the macOS menu bar (File / Edit / View / Note / Vault / Window). This is a QA requirement.
|
||||
|
||||
## macOS / Tauri Gotchas
|
||||
|
||||
- `Option+N` on macOS → special chars (`¡`, `™`), not `key:'N'`. Use `e.code` or `Cmd+N`.
|
||||
- Tauri menu accelerators: use `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")` — decorative text in labels doesn't register shortcuts.
|
||||
- `Option+N` → special chars on macOS. Use `e.code` or `Cmd+N`.
|
||||
- Tauri menu accelerators: `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")`.
|
||||
- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus.
|
||||
- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native app testing.
|
||||
|
||||
## QA Scripts
|
||||
|
||||
@@ -218,63 +86,8 @@ This principle applies to: themes, config files, type files, any `.laputa/` subf
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
|
||||
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)
|
||||
## Documentation Diagrams
|
||||
|
||||
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).
|
||||
|
||||
**Before marking any task done:**
|
||||
- Can the feature be triggered/used without touching the mouse?
|
||||
- If it requires clicking a button, add a command palette entry or keyboard shortcut
|
||||
- Document the shortcut in the command palette or menu bar
|
||||
|
||||
**If you add UI that is only reachable by mouse**, you must also add a keyboard path (command palette entry, shortcut, or Tab-navigable focus). No exceptions.
|
||||
|
||||
## Push Workflow (IMPORTANT — changed Feb 27, 2026)
|
||||
|
||||
**Push directly to main** — no PRs, no branches, no CI queue.
|
||||
|
||||
The pre-push hook runs all checks locally before the push goes through. This replaces remote CI.
|
||||
|
||||
```bash
|
||||
# After QA passes and you're ready to ship:
|
||||
git push origin main # pre-push hook runs automatically
|
||||
```
|
||||
|
||||
### ⛔ NEVER open a Pull Request
|
||||
PRs on separate branches diverge from main with every merge, requiring continuous rebases and creating unnecessary conflicts. Always push directly to main. If the push fails (disk full, test failure, etc.) — fix the problem, then push again. There is no scenario where opening a PR is the right fallback.
|
||||
|
||||
### ⛔ NEVER use --no-verify
|
||||
```bash
|
||||
# FORBIDDEN — will be caught and rejected:
|
||||
git push --no-verify
|
||||
git commit --no-verify # also forbidden for pre-push bypass
|
||||
```
|
||||
|
||||
The hook runs: tsc, Vite build, frontend tests, frontend coverage, Rust coverage, Clippy, rustfmt, CodeScene. Fix any failures before pushing — do not skip.
|
||||
|
||||
If a check fails, fix the issue and push again. The hook is the gate — not remote CI.
|
||||
Prefer Mermaid for all diagrams (`flowchart`, `sequenceDiagram`, `classDiagram`, `stateDiagram-v2`). ASCII only for spatial wireframe layouts. GitHub renders Mermaid natively.
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
# Code Health Report — Laputa App
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Branch:** `main`
|
||||
**Overall Project Score:** 9.33 / 10.0 (Green — up from 9.14)
|
||||
**Tool:** CodeScene Code Health Analysis (project ID: 76865)
|
||||
**Previous Report:** 2026-02-20 on `main` — 9.14 / 10.0
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Laputa App codebase scores **9.33** overall — a further improvement of **+0.19** from the previous report (9.14). The codebase remains solidly in **Green**, driven by the `vault.rs` refactoring (+2.59 to 8.81) and the `frontmatter.rs` refactoring (+2.79 to 9.68, now Green). Five files remain in the Yellow zone, down from six — `frontmatter.rs` has exited Yellow into Green.
|
||||
|
||||
| Zone | Score Range | File Count | Description |
|
||||
|------|------------|------------|-------------|
|
||||
| Optimal | 10.0 | 8 | Perfect — optimized for human and AI comprehension |
|
||||
| Green | 9.0 – 9.9 | 15 | High quality, minor issues only |
|
||||
| Yellow | 4.0 – 8.9 | 5 | Problematic technical debt |
|
||||
| Red | 1.0 – 3.9 | 0 | — |
|
||||
| N/A | — | 6 | CSS files (4) and tiny utility files (2) — unsupported by CodeScene |
|
||||
|
||||
---
|
||||
|
||||
## Refactoring Completed (vault.rs + frontmatter.rs)
|
||||
|
||||
The following refactorings were executed on vault.rs and frontmatter.rs, raising both files significantly:
|
||||
|
||||
### vault.rs: 6.22 → 8.81 (+2.59)
|
||||
|
||||
Refactored in 5 commits across multiple phases:
|
||||
|
||||
1. **Extracted `run_git` helper** — Consolidated duplicated git command execution into a single helper function, flattening git functions (`git_changed_files`, `git_uncommitted_new_files`).
|
||||
2. **Decomposed `parse_md_file`** — Extracted `parse_frontmatter_fields`, `extract_title`, `extract_snippet`, and `extract_relationships` into focused sub-functions. Flattened deep nesting with early returns.
|
||||
3. **Decomposed `scan_vault_cached`** — Extracted `process_vault_entry`, `collect_vault_entries`, `apply_git_status`, and `build_vault_response` as focused functions.
|
||||
4. **Split large test assertion blocks** — Broke monolithic assertion blocks into per-field assertions for readability and maintainability.
|
||||
5. **Converted internal functions to use `&Path`** instead of `&str` for vault/file paths, reducing string-heavy arguments.
|
||||
|
||||
All 8 original code smells (3 Bumpy Roads, 4 Deep Nestings, 2 Complex Methods, 2 Large Methods, String-Heavy Args, Large Assertion Blocks) have been resolved. The CodeScene review now reports **zero code smells**.
|
||||
|
||||
### frontmatter.rs: 6.89 → 9.68 (+2.79) — Yellow → Green
|
||||
|
||||
Refactored in 4 commits:
|
||||
|
||||
1. **Flattened `update_frontmatter_content`** — Used early returns and extracted `find_key_line_range` and `build_updated_content` helpers. Eliminated bumpy road (4 bumps) and deep nesting (4 levels).
|
||||
2. **Simplified `FrontmatterValue::to_yaml_value`** — Extracted `needs_yaml_quoting` predicate, simplified match arms. Reduced cc from 17.
|
||||
3. **Simplified `format_yaml_key`** — Extracted key-quoting rules into `key_needs_quoting` predicate. Reduced complex conditionals from 5.
|
||||
4. **Extracted line-parsing helpers** — `line_is_key` and related helpers for clean YAML line detection.
|
||||
|
||||
All original code smells (1 Bumpy Road, 1 Deep Nesting, 2 Complex Methods, 4 Complex Conditionals) have been resolved. Only one minor issue remains: **String Heavy Function Arguments** (73% of args are string types).
|
||||
|
||||
---
|
||||
|
||||
## Change Summary vs Previous Report (Feb 17)
|
||||
|
||||
| File | Previous | Current | Delta | Notes |
|
||||
|------|----------|---------|-------|-------|
|
||||
| `src/App.tsx` | 7.13 | **9.28** | **+2.15** | Yellow -> Green. Brain Method eliminated via hook extraction |
|
||||
| `src/components/Inspector.tsx` | 7.49 | **9.02** | **+1.53** | Yellow -> Green. Decomposed into sub-components |
|
||||
| `src-tauri/src/vault.rs` | 4.80 | **8.81** | **+4.01** | Still Yellow but near-Green. All code smells resolved |
|
||||
| `src-tauri/src/frontmatter.rs` | 6.89* | **9.68** | **+2.79** | Yellow -> Green. All major smells resolved |
|
||||
| `src/components/Editor.tsx` | 6.94 | **7.68** | **+0.74** | Still Yellow. DiffView/wikilinks extracted but Editor still too large |
|
||||
| `src/components/Sidebar.tsx` | 9.02 | **9.14** | +0.12 | Green (stable) |
|
||||
| `src/components/NoteList.tsx` | 8.11 | **8.05** | -0.06 | Yellow (stable, slight regression) |
|
||||
| `src/components/QuickOpenPalette.tsx` | 9.55 | **9.55** | = | Green (unchanged) |
|
||||
| `src-tauri/src/lib.rs` | 9.68 | **9.68** | = | Green (unchanged) |
|
||||
| `src-tauri/src/main.rs` | 10.0 | **10.0** | = | Optimal (unchanged) |
|
||||
| `src-tauri/src/git.rs` | 10.0 | **10.0** | = | Optimal (unchanged) |
|
||||
| `src/components/StatusBar.tsx` | 10.0 | **9.23** | -0.77 | Regression: Optimal -> Green (new features added) |
|
||||
| `src/mock-tauri.ts` | 10.0 | **9.37** | -0.63 | Regression: Optimal -> Green (new mock data added) |
|
||||
|
||||
*frontmatter.rs was extracted from vault.rs; "previous" is its initial score after extraction.
|
||||
|
||||
### New Files (not in previous report)
|
||||
|
||||
| File | Score | Zone | Notes |
|
||||
|------|-------|------|-------|
|
||||
| `src/hooks/useNoteActions.ts` | **7.81** | Yellow | Extracted from App.tsx — still needs decomposition |
|
||||
| `src/components/AIChatPanel.tsx` | **8.51** | Yellow | New feature — large component |
|
||||
| `src/components/DynamicPropertiesPanel.tsx` | **9.06** | Green | Extracted from Inspector.tsx |
|
||||
| `src/components/DiffView.tsx` | **9.09** | Green | Extracted from Editor.tsx |
|
||||
| `src/utils/frontmatter.ts` | **9.24** | Green | Extracted from Inspector.tsx |
|
||||
| `src/components/CommitDialog.tsx` | **9.38** | Green | New component |
|
||||
| `src/hooks/useVaultLoader.ts` | **9.41** | Green | Extracted from App.tsx |
|
||||
| `src/utils/wikilinks.ts` | **9.53** | Green | Extracted from Editor.tsx |
|
||||
| `src/hooks/useTheme.ts` | **9.68** | Green | New hook |
|
||||
| `src/components/EditableValue.tsx` | **10.0** | Optimal | Extracted from Inspector.tsx |
|
||||
| `src/components/ResizeHandle.tsx` | **10.0** | Optimal | New component |
|
||||
| `src/components/CreateNoteDialog.tsx` | **10.0** | Optimal | New component |
|
||||
| `src/components/Toast.tsx` | **10.0** | Optimal | New component |
|
||||
| `src/utils/typeColors.ts` | **10.0** | Optimal | New utility |
|
||||
| `src/main.tsx` | **10.0** | Optimal | Entry point |
|
||||
|
||||
---
|
||||
|
||||
## File-by-File Scores (All 34 Files)
|
||||
|
||||
| File | LoC | Score | Zone | Key Issues |
|
||||
|------|-----|-------|------|------------|
|
||||
| `src-tauri/src/main.rs` | 6 | **10.0** | Optimal | None |
|
||||
| `src-tauri/src/git.rs` | 423 | **10.0** | Optimal | None |
|
||||
| `src/components/EditableValue.tsx` | 167 | **10.0** | Optimal | None |
|
||||
| `src/components/ResizeHandle.tsx` | 74 | **10.0** | Optimal | None |
|
||||
| `src/components/CreateNoteDialog.tsx` | 99 | **10.0** | Optimal | None |
|
||||
| `src/components/Toast.tsx` | 28 | **10.0** | Optimal | None |
|
||||
| `src/utils/typeColors.ts` | 37 | **10.0** | Optimal | None |
|
||||
| `src/main.tsx` | 16 | **10.0** | Optimal | None |
|
||||
| `src-tauri/src/frontmatter.rs` | 279 | **9.68** | Green | String-heavy function arguments (73%) |
|
||||
| `src-tauri/src/lib.rs` | 80 | **9.68** | Green | String-heavy function arguments |
|
||||
| `src/hooks/useTheme.ts` | 51 | **9.68** | Green | None significant |
|
||||
| `src/components/QuickOpenPalette.tsx` | 145 | **9.55** | Green | Complex Method (cc=16) |
|
||||
| `src/utils/wikilinks.ts` | 68 | **9.53** | Green | None significant |
|
||||
| `src/hooks/useVaultLoader.ts` | 123 | **9.41** | Green | None significant |
|
||||
| `src/components/CommitDialog.tsx` | 73 | **9.38** | Green | None significant |
|
||||
| `src/mock-tauri.ts` | 894 | **9.37** | Green | None significant |
|
||||
| `src/App.tsx` | 176 | **9.28** | Green | Complex Method: App() cc=16 / 130 LoC |
|
||||
| `src/utils/frontmatter.ts` | 72 | **9.24** | Green | None significant |
|
||||
| `src/components/StatusBar.tsx` | 159 | **9.23** | Green | None significant |
|
||||
| `src/components/Sidebar.tsx` | 208 | **9.14** | Green | None significant |
|
||||
| `src/components/DiffView.tsx` | 45 | **9.09** | Green | None significant |
|
||||
| `src/components/DynamicPropertiesPanel.tsx` | 265 | **9.06** | Green | None significant |
|
||||
| `src/components/Inspector.tsx` | 312 | **9.02** | Green | None significant |
|
||||
| `src-tauri/src/vault.rs` | 1111 | **8.81** | Yellow | No code smells reported — near Green threshold |
|
||||
| `src/components/AIChatPanel.tsx` | 364 | **8.51** | Yellow | Complex Method: AIChatPanel() cc=15 / 285 LoC |
|
||||
| `src/components/NoteList.tsx` | 434 | **8.05** | Yellow | Complex Method: NoteListInner() cc=28 / 208 LoC |
|
||||
| `src/hooks/useNoteActions.ts` | 280 | **7.81** | Yellow | Bumpy Road, Deep Nesting, Complex Method: useNoteActions() cc=30 / 169 LoC |
|
||||
| `src/components/Editor.tsx` | 575 | **7.68** | Yellow | **Brain Method**: Editor() cc=61 / 385 LoC, Bumpy Road |
|
||||
| `src/types.ts` | 38 | N/A | — | Type definitions only |
|
||||
| `src/lib/utils.ts` | 6 | N/A | — | Utility (too small) |
|
||||
| `src/App.css` | — | N/A | — | CSS not supported |
|
||||
| `src/index.css` | — | N/A | — | CSS not supported |
|
||||
| `src/components/Editor.css` | — | N/A | — | CSS not supported |
|
||||
| `src/components/EditorTheme.css` | — | N/A | — | CSS not supported |
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt Hotspots
|
||||
|
||||
Based on code health scores, file sizes, and change frequency:
|
||||
|
||||
| Priority | File | Score | LoC | Risk Factor |
|
||||
|----------|------|-------|-----|-------------|
|
||||
| 1 | `src/components/Editor.tsx` | 7.68 | 575 | **Brain Method** (cc=61, 385 LoC) — worst single function in codebase |
|
||||
| 2 | `src/hooks/useNoteActions.ts` | 7.81 | 280 | Brain Method (cc=30, 169 LoC), deep nesting in updateMockFrontmatter |
|
||||
| 3 | `src/components/NoteList.tsx` | 8.05 | 434 | Complex Method (cc=28, 208 LoC) |
|
||||
| 4 | `src/components/AIChatPanel.tsx` | 8.51 | 364 | Large component (cc=15, 285 LoC) — new, address before it grows |
|
||||
| 5 | `src-tauri/src/vault.rs` | 8.81 | 1111 | Near-Green, no code smells — minor improvement needed to cross 9.0 |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Analysis — Files Scoring Below 9.0
|
||||
|
||||
### 1. `src/components/Editor.tsx` — Score: 7.68 (Now #1 Priority)
|
||||
|
||||
The core `Editor` component function remains a **Brain Method** — the single worst function in the codebase at cc=61 and 385 LoC (3.2x the 120 LoC limit).
|
||||
|
||||
**Code Smells Found:**
|
||||
|
||||
| Smell | Location | Details | Severity |
|
||||
|-------|----------|---------|----------|
|
||||
| Bumpy Road | `Editor` (L154–575) | 2 bumps | High |
|
||||
| Complex Method | `Editor` (L154–575) | cc = 61 (**Brain Method**) | High |
|
||||
| Complex Conditional | `Editor:196` | 2 complex expressions | Medium |
|
||||
| Large Method | `Editor` (L154–575) | 385 LoC (limit: 120) | Medium |
|
||||
|
||||
---
|
||||
|
||||
### 2. `src/hooks/useNoteActions.ts` — Score: 7.81
|
||||
|
||||
Extracted from App.tsx. Contains the `updateMockFrontmatter` function which has deep nesting, plus the `useNoteActions` hook itself is still too large.
|
||||
|
||||
**Code Smells Found:**
|
||||
|
||||
| Smell | Location | Details | Severity |
|
||||
|-------|----------|---------|----------|
|
||||
| Bumpy Road | `updateMockFrontmatter` (L14–66) | 2 bumps | High |
|
||||
| Deep Nesting | `updateMockFrontmatter` (L14–66) | 4 levels deep | High |
|
||||
| Complex Method | `useNoteActions` (L93–280) | cc = 30 | Medium |
|
||||
| Complex Method | `updateMockFrontmatter` (L14–66) | cc = 17 | Medium |
|
||||
| Complex Method | `deleteMockFrontmatterProperty` (L68–91) | cc = 9 | Medium |
|
||||
| Large Method | `useNoteActions` (L93–280) | 169 LoC (limit: 70) | Medium |
|
||||
|
||||
---
|
||||
|
||||
### 3. `src/components/NoteList.tsx` — Score: 8.05
|
||||
|
||||
Slightly regressed from 8.11. The `NoteListInner` component and `buildRelationshipGroups` remain complex.
|
||||
|
||||
**Code Smells Found:**
|
||||
|
||||
| Smell | Location | Details | Severity |
|
||||
|-------|----------|---------|----------|
|
||||
| Complex Method | `NoteListInner` (L211–432) | cc = 28 | Medium |
|
||||
| Complex Method | `buildRelationshipGroups` (L125–188) | cc = 13 | Medium |
|
||||
| Large Method | `NoteListInner` (L211–432) | 208 LoC (limit: 120) | Medium |
|
||||
| Overall Code Complexity | File-wide | High mean cyclomatic complexity | Medium |
|
||||
|
||||
---
|
||||
|
||||
### 4. `src/components/AIChatPanel.tsx` — Score: 8.51
|
||||
|
||||
New file (mock AI chat feature). Already showing signs of complexity that should be addressed early.
|
||||
|
||||
**Code Smells Found:**
|
||||
|
||||
| Smell | Location | Details | Severity |
|
||||
|-------|----------|---------|----------|
|
||||
| Complex Method | `AIChatPanel` (L62–364) | cc = 15 | Medium |
|
||||
| Large Method | `AIChatPanel` (L62–364) | 285 LoC (limit: 120) | Medium |
|
||||
|
||||
---
|
||||
|
||||
### 5. `src-tauri/src/vault.rs` — Score: 8.81
|
||||
|
||||
Dramatically improved from 6.22. The CodeScene review reports **zero code smells** after the refactoring. The file is near the Green threshold (9.0) and may only need minor adjustments to cross it.
|
||||
|
||||
---
|
||||
|
||||
## Quick Wins (Low Effort, High Impact)
|
||||
|
||||
### 1. Decompose `Editor` into hooks (highest ROI)
|
||||
**File:** `src/components/Editor.tsx` | **Impact:** cc 61 -> ~10 per hook
|
||||
- Extract `useEditorExtensions()` — all CodeMirror extension setup (themes, keybindings, decorations)
|
||||
- Extract `useEditorContent()` — content loading, saving, dirty state management
|
||||
- Extract `useEditorKeymap()` — custom keymap handlers
|
||||
- The `Editor` component becomes a thin composition + JSX layer
|
||||
|
||||
### 2. Decompose `useNoteActions` hook
|
||||
**File:** `src/hooks/useNoteActions.ts` | **Impact:** cc 30 -> ~8 per hook
|
||||
- Extract `useFrontmatterSync()` — `updateMockFrontmatter` + `deleteMockFrontmatterProperty`
|
||||
- Flatten `updateMockFrontmatter` with early returns and helper functions
|
||||
- Keep `useNoteActions` as pure action dispatch (create, delete, rename)
|
||||
|
||||
### 3. Split `NoteListInner` into sub-components
|
||||
**File:** `src/components/NoteList.tsx` | **Impact:** cc 28 -> ~8 per component
|
||||
- Extract `NoteListItem` component for individual note rendering
|
||||
- Extract `RelationshipGroup` component for grouped entries
|
||||
- Extract `buildRelationshipGroups` to a utility file
|
||||
|
||||
### 4. Extract `AIChatPanel` hooks early
|
||||
**File:** `src/components/AIChatPanel.tsx` | **Impact:** Prevent further complexity growth
|
||||
- Extract `useChatMessages()` — message state, send/receive logic
|
||||
- Extract `ChatMessage` component for individual message rendering
|
||||
|
||||
### 5. Push `vault.rs` past 9.0
|
||||
**File:** `src-tauri/src/vault.rs` | **Impact:** 8.81 -> 9.0+
|
||||
- Minor: reduce string-heavy args further with `&Path` conversions
|
||||
- Minor: simplify any remaining complex expressions
|
||||
|
||||
---
|
||||
|
||||
## Path to 9.5 Overall
|
||||
|
||||
**Current:** 9.33 (28 scored files, sum = 261.20)
|
||||
**Target:** 9.5
|
||||
|
||||
To reach 9.5, all 5 Yellow files must reach at least 9.5:
|
||||
|
||||
| File | Current | Target | Points Needed |
|
||||
|------|---------|--------|---------------|
|
||||
| `vault.rs` | 8.81 | 9.5 | +0.69 |
|
||||
| `Editor.tsx` | 7.68 | 9.5 | +1.82 |
|
||||
| `useNoteActions.ts` | 7.81 | 9.5 | +1.69 |
|
||||
| `NoteList.tsx` | 8.05 | 9.5 | +1.45 |
|
||||
| `AIChatPanel.tsx` | 8.51 | 9.5 | +0.99 |
|
||||
| **Total points needed** | | | **+6.64** |
|
||||
|
||||
**Projected score if all Yellow files reach 9.5:** (261.20 + 6.64) / 28 = **9.57**
|
||||
|
||||
**Recommended execution order for maximum impact:**
|
||||
1. `Editor.tsx` (7.68 -> 9.5) — highest user-facing impact, hook extraction is mechanical
|
||||
2. `useNoteActions.ts` (7.81 -> 9.5) — extracted hook, straightforward decomposition
|
||||
3. `NoteList.tsx` (8.05 -> 9.5) — component extraction
|
||||
4. `AIChatPanel.tsx` (8.51 -> 9.5) — closest to target, prevent drift
|
||||
5. `vault.rs` (8.81 -> 9.5) — near-Green already, minor tweaks
|
||||
|
||||
---
|
||||
|
||||
## Refactoring ROI Summary
|
||||
|
||||
| File | Current | Target | Defect Reduction | Speed Improvement |
|
||||
|------|---------|--------|------------------|-------------------|
|
||||
| `Editor.tsx` | 7.68 | 9.5 | 25–38% | 19–30% |
|
||||
| `useNoteActions.ts` | 7.81 | 9.5 | 24–36% | 18–28% |
|
||||
| `NoteList.tsx` | 8.05 | 9.5 | 22–33% | 16–26% |
|
||||
| `AIChatPanel.tsx` | 8.51 | 9.5 | 18–27% | 13–21% |
|
||||
| `vault.rs` | 8.81 | 9.5 | 10–18% | 8–14% |
|
||||
|
||||
---
|
||||
|
||||
## Files in Good Shape
|
||||
|
||||
These files need no immediate attention:
|
||||
|
||||
**Optimal (10.0):**
|
||||
- `src-tauri/src/main.rs` — 6 LoC, clean entry point
|
||||
- `src-tauri/src/git.rs` — 423 LoC, well-structured
|
||||
- `src/components/EditableValue.tsx` — 167 LoC, clean extracted component
|
||||
- `src/components/ResizeHandle.tsx` — 74 LoC, simple component
|
||||
- `src/components/CreateNoteDialog.tsx` — 99 LoC, clean dialog
|
||||
- `src/components/Toast.tsx` — 28 LoC, minimal component
|
||||
- `src/utils/typeColors.ts` — 37 LoC, simple utility
|
||||
- `src/main.tsx` — 16 LoC, entry point
|
||||
|
||||
**Green (9.0–9.9):**
|
||||
- `src-tauri/src/frontmatter.rs` — 9.68 (up from 6.89! Only: string-heavy args)
|
||||
- `src-tauri/src/lib.rs` — 9.68 (minor: string-heavy args)
|
||||
- `src/hooks/useTheme.ts` — 9.68 (clean hook)
|
||||
- `src/components/QuickOpenPalette.tsx` — 9.55 (minor: cc=16)
|
||||
- `src/utils/wikilinks.ts` — 9.53 (clean utility)
|
||||
- `src/hooks/useVaultLoader.ts` — 9.41 (clean hook)
|
||||
- `src/components/CommitDialog.tsx` — 9.38 (clean component)
|
||||
- `src/mock-tauri.ts` — 9.37 (large but clean)
|
||||
- `src/App.tsx` — 9.28 (dramatically improved from 7.13)
|
||||
- `src/utils/frontmatter.ts` — 9.24 (clean utility)
|
||||
- `src/components/StatusBar.tsx` — 9.23 (slightly regressed from 10.0)
|
||||
- `src/components/Sidebar.tsx` — 9.14 (stable)
|
||||
- `src/components/DiffView.tsx` — 9.09 (clean extracted component)
|
||||
- `src/components/DynamicPropertiesPanel.tsx` — 9.06 (clean extracted component)
|
||||
- `src/components/Inspector.tsx` — 9.02 (dramatically improved from 7.49)
|
||||
|
||||
---
|
||||
|
||||
## What Worked Since Last Report
|
||||
|
||||
The following refactorings from the Feb 17 recommendations were executed:
|
||||
|
||||
1. **App.tsx decomposition** (Plan C) — Extracted `useNoteActions`, `useVaultLoader`, and other hooks. App dropped from cc=56/381 LoC to cc=16/130 LoC. Score: 7.13 -> 9.28.
|
||||
2. **Inspector.tsx decomposition** (Plan D) — Extracted `DynamicPropertiesPanel`, `EditableValue`, and `frontmatter.ts` utility. Score: 7.49 -> 9.02.
|
||||
3. **vault.rs full refactoring** (Plan A) — Extracted `run_git` helper, decomposed `parse_md_file` and `scan_vault_cached`, split large assertion blocks, converted to `&Path` args. Score: 4.80 -> 8.81. **All code smells resolved.**
|
||||
4. **frontmatter.rs full refactoring** (Plan B) — Flattened `update_frontmatter_content`, simplified `to_yaml_value` and `format_yaml_key`, extracted line-parsing helpers. Score: 6.89 -> 9.68. **Yellow -> Green.**
|
||||
5. **Editor.tsx partial decomposition** (Plan B, Steps 2–3) — Extracted `DiffView.tsx` and `wikilinks.ts`. Score: 6.94 -> 7.68.
|
||||
|
||||
## What Still Needs Work
|
||||
|
||||
1. **Editor.tsx** — DiffView and wikilinks were extracted, but the core Editor function was NOT decomposed into hooks. It's now the worst function (cc=61, 385 LoC). Hook extraction (useEditorExtensions, useEditorContent, useEditorKeymap) is the next high-impact target.
|
||||
2. **useNoteActions.ts** — Inherited App.tsx's `updateMockFrontmatter` complexity. Needs decomposition into smaller hooks.
|
||||
3. **NoteList.tsx** — Slight regression, needs component extraction (NoteListItem, RelationshipGroup).
|
||||
4. **AIChatPanel.tsx** — New file already showing complexity. Address early before it grows.
|
||||
5. **vault.rs** — Near-Green at 8.81 with zero code smells. Minor tweaks may push it past 9.0.
|
||||
|
||||
---
|
||||
|
||||
*Report generated by CodeScene MCP analysis on 2026-02-20. For interactive exploration, visit: https://codescene.io/projects/76865*
|
||||
*Note: CodeScene MCP Server MCP-0.1.5 was used. Version MCP-0.2.0 is available — consider updating via `brew upgrade cs-mcp`.*
|
||||
362
REDESIGN-PLAN.md
362
REDESIGN-PLAN.md
@@ -1,362 +0,0 @@
|
||||
# Laputa App Redesign — Implementation Plan
|
||||
|
||||
> Generated from `ui-design.pen` (V2) vs current implementation. **Analysis only — do not implement yet.**
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
The V2 design introduces: a **Status Bar**, **Tab Bar** in the editor, an **Info Bar** (breadcrumb + actions), restructured **Sidebar** with Phosphor icons and collapsible groups with count badges, **IBM Plex Mono** for type pills, updated **color palette** (new primary `#155DFF`, new accent colors), and several layout/spacing refinements throughout.
|
||||
|
||||
---
|
||||
|
||||
## Design Specs Reference (from .pen file)
|
||||
|
||||
### Colors Changed
|
||||
| Variable | Old (Light) | New (Light) | Old (Dark) | New (Dark) |
|
||||
|---|---|---|---|---|
|
||||
| `--primary` | `#2383E2` | `#155DFF` | `#4a9eff` | `#155DFF` |
|
||||
| `--accent-green` | `#0F7B6C` | `#00B38B` | `#4caf50` | `#00B38B` |
|
||||
| `--accent-purple` | `#9065B0` | `#A932FF` | `#9c72ff` | `#A932FF` |
|
||||
| `--accent-blue` | `#2383E2` | `#155DFF` | `#4a9eff` | `#155DFF` |
|
||||
|
||||
### New Color Variables (not in current CSS)
|
||||
| Variable | Light | Dark |
|
||||
|---|---|---|
|
||||
| `--accent-yellow` | `#F0B100` | `#F0B100` |
|
||||
| `--accent-blue-light` | `#155DFF14` | `#155DFF20` |
|
||||
| `--accent-green-light` | `#00B38B14` | `#00B38B20` |
|
||||
| `--accent-purple-light` | `#A932FF14` | `#A932FF20` |
|
||||
| `--accent-red-light` | `#E03E3E14` | `#f4433620` |
|
||||
| `--accent-yellow-light` | `#F0B10014` | `#F0B10020` |
|
||||
|
||||
### Typography
|
||||
- **Font**: Inter (primary), IBM Plex Mono (labels/pills) — **IBM Plex Mono not currently loaded**
|
||||
- App title: 17px / Bold / letter-spacing -0.3
|
||||
- Sidebar items: 13px / Medium (font-weight 500)
|
||||
- Sidebar section headers: 13px / Semibold (600) — currently 11px
|
||||
- Type pills: 11px / IBM Plex Mono / normal weight / ALL CAPS
|
||||
- Editor H1: 32px / Bold / lh 1.2
|
||||
- Editor H2: 24px / Semibold / lh 1.3
|
||||
- Editor body: 16px / Regular / lh 1.6
|
||||
- Info bar / breadcrumb: 12px
|
||||
- Status bar: 11px
|
||||
|
||||
### Panel Widths
|
||||
| Panel | Design | Current |
|
||||
|---|---|---|
|
||||
| Sidebar | 250px | 250px ✅ |
|
||||
| NoteList | 300px | 300px ✅ |
|
||||
| Editor | flexible | flexible ✅ |
|
||||
| Inspector | 260px (design) / 280px (spec) | 280px ✅ |
|
||||
|
||||
### Border Radius Scale
|
||||
- 4px (sm) — chips
|
||||
- 6px (md) — buttons, inputs
|
||||
- 8px (lg) — cards, dialogs
|
||||
- 9999px — pills, badges (full-round)
|
||||
- 16px — larger badges
|
||||
|
||||
---
|
||||
|
||||
## Difference Map
|
||||
|
||||
### 1. NEW: Status Bar (bottom of app)
|
||||
**Files**: New component `StatusBar.tsx`, `App.tsx`, `App.css`
|
||||
- 30px height, `bg: --sidebar`, `border-top: 1px --border`
|
||||
- **Left**: box icon + "v0.4.2" | git-branch + "main" | refresh-cw (green) + "Synced 2m ago"
|
||||
- **Right**: sparkles (purple) + "Claude Sonnet 4" | file-text + "1,247 notes" | bell icon | settings icon
|
||||
- Padding: 0 8px, items aligned center, gap 12px between items
|
||||
- Font: Inter 11px, text color `--muted-foreground`
|
||||
- Separators: "|" in `--border` color
|
||||
- **All icons**: Lucide, 13-14px
|
||||
|
||||
### 2. NEW: Tab Bar (top of editor panel)
|
||||
**Files**: `Editor.tsx`
|
||||
- 45px height, `bg: --sidebar`, `border-bottom: 1px --sidebar-border`
|
||||
- **Active tab**: `bg: --background`, border-right 1px `--border`, text 12px/500 `--foreground`, X close icon (14px lucide)
|
||||
- **Inactive tab**: no fill, border-right + border-bottom 1px `--sidebar-border`, text 12px/normal `--muted-foreground`, X icon opacity 0 (show on hover)
|
||||
- **Spacer**: fills remaining width, border-bottom 1px `--border`
|
||||
- **Controls area** (right): border-left + border-bottom 1px `--border`, gap 12px, padding 0 12px
|
||||
- Plus icon (Phosphor, 16px)
|
||||
- Columns/split icon (Phosphor, 16px) — **disabled placeholder**
|
||||
- Arrows-out-simple/expand icon (Phosphor, 16px) — **disabled placeholder**
|
||||
|
||||
### 3. NEW: Breadcrumb Bar (below tab bar, above editor content)
|
||||
**Files**: `Editor.tsx`
|
||||
- 45px height, `bg: --background`, `border-bottom: 1px --border`
|
||||
- Padding: 6px 16px
|
||||
- **Left (breadcrumb)**: "Project" (12px, muted) › "Laputa App" (12px/500, foreground) · "1,284 words" (12px, muted) · "M" (12px/600, `--accent-yellow`) — M only when file modified
|
||||
- **Right (actions)**: gap 12px, each 16px Phosphor icon in `--muted-foreground`
|
||||
- magnifying-glass (search in file)
|
||||
- git-branch (version history) — **disabled placeholder**
|
||||
- cursor-text (focus mode) — **disabled placeholder**
|
||||
- sparkle (AI assist) — **disabled placeholder**
|
||||
- dots-three (more options) — **disabled placeholder**
|
||||
|
||||
### 4. Sidebar Restructure
|
||||
**Files**: `Sidebar.tsx`
|
||||
|
||||
#### Header changes:
|
||||
- Current: "Laputa" title + theme toggle button
|
||||
- New: "Laputa" title (17px/700, -0.3 ls) + search icon (16px Phosphor magnifying-glass) + settings/gear icon (16px)
|
||||
- Theme toggle moved elsewhere (or removed from header)
|
||||
- Padding: 12px 16px, height 45px, border-bottom 1px
|
||||
|
||||
#### Search bar added:
|
||||
- Below header, padding 6px 12px, border-bottom 1px
|
||||
- Input with magnifying-glass icon prefix, 13px text, placeholder "Search notes..."
|
||||
- Height ~32px, border-radius 6px, bg `--secondary`
|
||||
|
||||
#### Navigation section restructured:
|
||||
**Current**: flat list of filters (All Notes, People, Events, Changes, Favorites, Trash)
|
||||
**New**: Two items in top nav:
|
||||
- "All Notes" — file-text icon (Phosphor 16px) + label 13px/500 + count badge (pill, bg `--secondary`, 10px text)
|
||||
- "Favorites" — star icon (Phosphor 16px) + same style
|
||||
|
||||
#### Section groups restructured:
|
||||
**Current**: PROJECTS, EXPERIMENTS, RESPONSIBILITIES, PROCEDURES as expandable sections with items listed under each
|
||||
**New**: Collapsible groups with consistent pattern:
|
||||
- Each group: chevron-right (12px Lucide) + icon (18px Phosphor, bold) + label (13px/600) + count badge (pill)
|
||||
- **Projects** — folder-open icon (Phosphor)
|
||||
- **Experiments** — flask icon (Phosphor)
|
||||
- **Responsibilities** — target icon (Phosphor) — **currently not in sidebar**
|
||||
- **Procedures** — arrows-clockwise icon (Phosphor)
|
||||
- **People** — users icon (Phosphor) — **moved from filter to section group**
|
||||
- **Events** — calendar-blank icon (Phosphor) — **moved from filter to section group**
|
||||
- **Topics** — tag icon (Phosphor) — **currently at bottom, now integrated as a group**
|
||||
|
||||
Each group has:
|
||||
- Container: padding 4px 6px, border-bottom 1px (disabled in some), vertical layout, gap 2px
|
||||
- Header row: padding 6px 16px, corner-radius 4px, gap 8px, justify space-between
|
||||
- Badge: height 20px, bg `--secondary`, corner-radius 9999px, padding 0 6px
|
||||
|
||||
#### Removed from sidebar:
|
||||
- "Untagged" filter — not in new design
|
||||
- "Changes" filter — not in new design (modified files shown elsewhere)
|
||||
- "Trash" filter — not in new design
|
||||
- "People" as top-level filter — now a collapsible section group
|
||||
- "Events" as top-level filter — now a collapsible section group
|
||||
|
||||
#### Commit button:
|
||||
- Same concept but refined: padding 12px, border-top 1px
|
||||
- Button: fill `--primary`, corner-radius 6px, gap 6px, padding 8px 16px
|
||||
- Icon: git-commit-horizontal (Lucide 14px) in `--primary-foreground`
|
||||
- Text: "Commit & Push" (13px/500)
|
||||
- Badge: bg `#ffffff40`, corner-radius 9px, text `--white` 10px/600
|
||||
|
||||
### 5. NoteList Changes
|
||||
**Files**: `NoteList.tsx`
|
||||
|
||||
#### Header:
|
||||
- Current: title + count badge + create button
|
||||
- New: "Notes" title (14px/600) + search icon (16px Phosphor) + plus icon (16px Phosphor) — gap 12px
|
||||
- No separate count badge in header
|
||||
|
||||
#### Search:
|
||||
- Current: always-visible search input below header
|
||||
- New: search icon in header (search may toggle inline or use command palette)
|
||||
- **Remove the always-visible search input** or keep it hidden until search icon clicked
|
||||
|
||||
#### Type pills:
|
||||
- Current: rounded-full, border, `text-[11px]`, system font, "Projects 4" format
|
||||
- New: `IBM Plex Mono` font, 11px, ALL CAPS format "ALL 24" / "PROJECTS 4" / "NOTES 12" / "EVENTS 5"
|
||||
- Active pill: `bg: #4a9eff18` (blue tint), `border: 1px --primary`, text `--primary`
|
||||
- Inactive pill: `border: 1px --border`, text `--muted-foreground`
|
||||
- Pill padding: 2px 10px, corner-radius 9999px
|
||||
- Height: ~18px (compact)
|
||||
- Layout: absolute positioned at x offsets (12, 76, 166, 243) within 45px height container — effectively a horizontal scrollable row
|
||||
|
||||
#### Note items:
|
||||
- Selected: `bg: #2383E212` (very light blue), left accent bar 3px `#2383E2`, title 13px/600
|
||||
- Normal: border-bottom 1px `#E9E9E7`, title 13px/500, time 11px, snippet 12px/lh1.5
|
||||
- Padding: 10px 16px
|
||||
- **No type badge** on individual items (simplified)
|
||||
- **No status text** on items
|
||||
|
||||
### 6. Editor Content Area
|
||||
**⚠️ SKIP — Keep editor as-is. Editor changes in the design are NOT intentional.**
|
||||
|
||||
### 7. Inspector Refinements
|
||||
**Files**: `Inspector.tsx`
|
||||
|
||||
#### Header:
|
||||
- Current: collapsed toggle + title
|
||||
- New: sliders-horizontal icon (16px Phosphor) + "Properties" (13px/600, `--muted-foreground`) + X close button (16px Phosphor)
|
||||
- Height 45px, border-bottom 1px, padding 0 12px, gap 8px
|
||||
|
||||
#### Properties section:
|
||||
- Key-value rows: label (12px, muted) — value (12px, foreground), space-between
|
||||
- Status badge: colored bg (e.g., `--accent-green-light`) with colored text (e.g., `--accent-green`), rounded, padding 1px 6px, 10px font
|
||||
- "+ Add property" button: full-width, border 1px `--border`, corner-radius 6px, padding 6px 12px, centered text (12px, muted)
|
||||
|
||||
#### Relationships section:
|
||||
- Group title: 12px/600 foreground
|
||||
- Link buttons: full-width, bg `--accent-blue-light`, corner-radius 6px, padding 6px 10px, text `--primary` 12px/500, icon (tag/flask, Phosphor 14px, 0.5 opacity)
|
||||
- "+ Link existing" button: border 1px `--border`, corner-radius 6px, same padding
|
||||
|
||||
#### Backlinks:
|
||||
- Title: "Backlinks" 12px/600 + count 11px/500 muted
|
||||
- Items: text `--primary` 12px
|
||||
|
||||
#### History:
|
||||
- Title: "History" 12px/600
|
||||
- Items: left border 2px `--border`, padding-left 10px
|
||||
- Hash line: 11px foreground
|
||||
- Date line: 10px muted
|
||||
|
||||
### 8. Icon Library Change
|
||||
**Current**: Lucide React throughout
|
||||
**New**: **SF Symbols** (Apple's native icon set) for all new/redesigned icons. Use `sf-symbols-react` or inline SVGs extracted from SF Symbols app.
|
||||
|
||||
**Note**: The Pencil design used Phosphor as a placeholder — Luca's intent is SF Symbols throughout. Map Phosphor names to SF Symbol equivalents:
|
||||
- `magnifying-glass` → `magnifyingglass`
|
||||
- `star` → `star.fill`
|
||||
- `folder-open` → `folder`
|
||||
- `flask` → `flask`
|
||||
- `target` → `target`
|
||||
- `arrows-clockwise` → `arrow.clockwise`
|
||||
- `users` → `person.2`
|
||||
- `calendar-blank` → `calendar`
|
||||
- `tag` → `tag`
|
||||
- `plus` → `plus`
|
||||
- `columns` → `rectangle.split.2x1`
|
||||
- `arrows-out-simple` → `arrow.up.left.and.arrow.down.right`
|
||||
- `sliders-horizontal` → `slider.horizontal.3`
|
||||
- `cursor-text` → `character.cursor.ibeam`
|
||||
- `sparkle` → `sparkles`
|
||||
- `dots-three` → `ellipsis`
|
||||
- `git-branch` → `arrow.triangle.branch`
|
||||
- `gear` → `gearshape`
|
||||
|
||||
**Action**: Find the best approach for SF Symbols in React/Tauri (e.g., `sf-symbols-react`, SVG extraction, or native font)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Theme & Typography Updates
|
||||
**Scope**: CSS variables, fonts, colors — no structural changes
|
||||
**Files**: `src/index.css`, `index.html` (or font import)
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **Add IBM Plex Mono font** — add Google Fonts import or npm package
|
||||
2. **Update color variables in `index.css`**:
|
||||
- `:root` (light): `--primary: #155DFF`, `--accent-green: #00B38B`, `--accent-purple: #A932FF`, `--accent-blue: #155DFF`
|
||||
- `.dark`: same primary `#155DFF`, accent-green `#00B38B`, accent-purple `#A932FF`
|
||||
- Add new variables: `--accent-yellow`, `--accent-blue-light`, `--accent-green-light`, `--accent-purple-light`, `--accent-red-light`, `--accent-yellow-light` (both modes)
|
||||
- Update all `--ring`, `--sidebar-primary`, `--sidebar-ring` to match new primary
|
||||
- Update app-specific vars: `--accent-blue`, `--accent-green`, `--accent-purple`, `--accent-blue-bg` etc.
|
||||
3. **Update `theme.json`**:
|
||||
- `headings.h2.fontSize`: 27 → 24
|
||||
- `editor.paddingHorizontal`: 40 → 64
|
||||
- `editor.paddingVertical`: 20 → 32
|
||||
4. **Install Phosphor Icons**: `pnpm add @phosphor-icons/react`
|
||||
|
||||
### Phase 2: Sidebar Restructure
|
||||
**Scope**: Sidebar layout, navigation, icons
|
||||
**Files**: `src/components/Sidebar.tsx`
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **Header**: Replace theme toggle with search icon (Phosphor `MagnifyingGlass`) + gear icon. Move theme toggle to status bar settings or a menu.
|
||||
2. **Add search input** below header: Phosphor magnifying-glass prefix, 13px, bg `--secondary`, border-radius 6px
|
||||
3. **Top nav**: Reduce to "All Notes" (Phosphor `FileText` 16px) and "Favorites" (Phosphor `Star` 16px), each with count badge pill
|
||||
4. **Section groups**: Restructure to new pattern with:
|
||||
- Consistent chevron + Phosphor icon (18px, bold) + label (13px/600) + count badge
|
||||
- Icons: `FolderOpen` (Projects), `Flask` (Experiments), `Target` (Responsibilities), `ArrowsClockwise` (Procedures), `Users` (People), `CalendarBlank` (Events), `Tag` (Topics)
|
||||
- Move People and Events from filters to section groups
|
||||
- Remove "Untagged", "Changes", "Trash" from nav
|
||||
5. **Commit button**: Update styling to match design (padding, badge style)
|
||||
6. **Remove** People/Events/Changes/Trash/Untagged filter items
|
||||
|
||||
### Phase 3: NoteList Updates
|
||||
**Scope**: Header, type pills, note item styling
|
||||
**Files**: `src/components/NoteList.tsx`
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **Header**: Replace badge + create button with search icon (Phosphor `MagnifyingGlass`) + plus icon (Phosphor `Plus`), gap 12px
|
||||
2. **Remove or hide** the always-visible search input — add toggle behavior on search icon click
|
||||
3. **Type pills**: Switch to IBM Plex Mono, ALL CAPS format ("ALL 24", "PROJECTS 4"), update active/inactive styles per design
|
||||
4. **Selected note**: Update to `bg: #2383E212`, left accent 3px `#2383E2` (update to new primary), remove type badge and status text from items
|
||||
5. **Note items**: Adjust padding to 10px 16px, snippet line-height 1.5, remove type/status badges from individual items
|
||||
|
||||
### Phase 4: Editor — Tab Bar & Info Bar
|
||||
**Scope**: New sub-components within Editor
|
||||
**Files**: `src/components/Editor.tsx`, `src/components/Editor.css`
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **Tab Bar** (top of editor):
|
||||
- 45px, bg `--sidebar`, border-bottom
|
||||
- Active tab: bg `--background`, border-right, 12px/500 text, X close button
|
||||
- Inactive tab: muted text, hidden X (show on hover)
|
||||
- Right controls: Plus + Split (disabled) + Expand (disabled) — Phosphor icons
|
||||
2. **Info Bar** (below tab bar):
|
||||
- 45px, bg `--background`, border-bottom
|
||||
- Left: breadcrumb `Type › Title · N words · M` (M in accent-yellow when modified)
|
||||
- Right: icon buttons (magnifying-glass functional, git-branch/cursor-text/sparkle/dots-three as **disabled placeholders** with `opacity: 0.4, cursor: not-allowed`)
|
||||
3. **Adjust editor content padding** to 32px 64px per design
|
||||
|
||||
### Phase 5: Status Bar + Inspector Polish
|
||||
**Scope**: New StatusBar component, Inspector refinements
|
||||
**Files**: New `src/components/StatusBar.tsx`, `App.tsx`, `App.css`, `src/components/Inspector.tsx`
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **StatusBar.tsx** (new component):
|
||||
- 30px fixed at bottom, bg `--sidebar`, border-top 1px
|
||||
- Left: version + branch + sync status
|
||||
- Right: AI model + notes count + bell (disabled placeholder) + settings (disabled placeholder)
|
||||
- All Lucide icons 13-14px
|
||||
2. **App.tsx / App.css**: Add StatusBar below main content, wrap layout in vertical flex (main panels + status bar)
|
||||
3. **Inspector refinements**:
|
||||
- Header: Phosphor `SlidersHorizontal` icon + "Properties" label + Phosphor `X` close
|
||||
- Status badge: use `--accent-*-light` bg colors with `--accent-*` text
|
||||
- "+ Add property" and "+ Link existing" buttons: match border/radius/padding from design
|
||||
- History items: left-border 2px timeline style, 10px date text
|
||||
|
||||
### Phase 6: Icon Migration & Cleanup
|
||||
**Scope**: Replace Lucide icons with Phosphor where specified
|
||||
**Files**: All components
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **Audit all icon usage** across components
|
||||
2. **Replace with Phosphor** where the design specifies (sidebar nav, section icons, NoteList header, editor toolbar icons, inspector)
|
||||
3. **Keep Lucide** for: chevrons, X/close, tab close, status bar icons, git-commit-horizontal
|
||||
4. **Remove unused Lucide imports**
|
||||
5. **Visual verification**: Run `pnpm dev` and compare with `ui-design-screenshot.png`
|
||||
|
||||
---
|
||||
|
||||
## New Features as Disabled Placeholders
|
||||
|
||||
These buttons/icons appear in the design but don't have backend functionality yet. Add them as disabled UI elements:
|
||||
|
||||
| Element | Location | Icon | Notes |
|
||||
|---|---|---|---|
|
||||
| Split view | Tab bar controls | Phosphor `Columns` | `opacity: 0.4, cursor: not-allowed, title="Coming soon"` |
|
||||
| Expand/focus | Tab bar controls | Phosphor `ArrowsOutSimple` | Same |
|
||||
| Git branch viewer | Info bar right | Phosphor `GitBranch` | Same |
|
||||
| Focus mode | Info bar right | Phosphor `CursorText` | Same |
|
||||
| AI assist | Info bar right | Phosphor `Sparkle` | Same |
|
||||
| More options | Info bar right | Phosphor `DotsThree` | Same |
|
||||
| Bell/notifications | Status bar right | Lucide `Bell` | Same |
|
||||
| Settings | Status bar right | Lucide `Settings` | Same |
|
||||
| Gear/settings | Sidebar header | Phosphor `Gear` | Same |
|
||||
|
||||
---
|
||||
|
||||
## Files Inventory
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `src/index.css` | Color variables, font import |
|
||||
| `src/theme.json` | H2 size, editor padding |
|
||||
| `index.html` | IBM Plex Mono font link (if using CDN) |
|
||||
| `package.json` | Add `@phosphor-icons/react` |
|
||||
| `src/App.tsx` | Add StatusBar, adjust layout |
|
||||
| `src/App.css` | Vertical flex for status bar |
|
||||
| `src/components/Sidebar.tsx` | Major restructure |
|
||||
| `src/components/NoteList.tsx` | Header, pills, item styling |
|
||||
| `src/components/Editor.tsx` | Add TabBar, InfoBar sections |
|
||||
| `src/components/Editor.css` | Tab/info bar styles |
|
||||
| `src/components/Inspector.tsx` | Header, badges, history styling |
|
||||
| `src/components/StatusBar.tsx` | **NEW** |
|
||||
@@ -1,130 +0,0 @@
|
||||
# SF Symbols Migration Plan
|
||||
|
||||
> Current state: All icons use either **Phosphor Icons** (`@phosphor-icons/react`) or **Lucide React** (`lucide-react`). This document maps every icon to its SF Symbol equivalent for future migration.
|
||||
|
||||
---
|
||||
|
||||
## Icon Audit Summary (Phase 6 — 2026-02-17)
|
||||
|
||||
| Category | Count | Files | Status |
|
||||
|---|---|---|---|
|
||||
| Phosphor icons | 22 | `Sidebar.tsx`, `Editor.tsx`, `NoteList.tsx`, `Inspector.tsx` | All used, migrate to SF Symbols |
|
||||
| Phosphor types | 1 (`IconProps`) | `Sidebar.tsx` | Type only — replace when migrating |
|
||||
| Lucide (app components) | 4 | `Sidebar.tsx`, `Editor.tsx` | Evaluate per-icon |
|
||||
| Lucide (StatusBar) | 7 | `StatusBar.tsx` | Keep Lucide per design |
|
||||
| Lucide (shadcn/ui) | 7 | `ui/select.tsx`, `ui/dropdown-menu.tsx`, `ui/dialog.tsx` | Keep Lucide — library internals |
|
||||
| **Total icon imports** | **41** | **8 files** | **0 unused** |
|
||||
|
||||
**Unused imports found**: None. All icon imports are actively used in JSX.
|
||||
|
||||
---
|
||||
|
||||
## Phosphor Icons — Current Usage
|
||||
|
||||
These are the primary UI icons introduced during the redesign. All should migrate to SF Symbols.
|
||||
|
||||
| Phosphor Icon | SF Symbol Equivalent | File(s) | Usage |
|
||||
|---|---|---|---|
|
||||
| `MagnifyingGlass` | `magnifyingglass` | `Sidebar.tsx`, `NoteList.tsx`, `Editor.tsx` | Search icon in sidebar header, note list header, editor info bar |
|
||||
| `Gear` | `gearshape` | `Sidebar.tsx` | Settings icon in sidebar header (disabled placeholder) |
|
||||
| `FileText` | `doc.text` | `Sidebar.tsx` | "All Notes" nav item icon |
|
||||
| `Star` | `star.fill` | `Sidebar.tsx` | "Favorites" nav item icon |
|
||||
| `FolderOpen` | `folder` | `Sidebar.tsx` | "Projects" section group icon |
|
||||
| `Flask` | `flask` | `Sidebar.tsx` | "Experiments" section group icon |
|
||||
| `Target` | `target` | `Sidebar.tsx` | "Responsibilities" section group icon |
|
||||
| `ArrowsClockwise` | `arrow.clockwise` | `Sidebar.tsx` | "Procedures" section group icon |
|
||||
| `Users` | `person.2` | `Sidebar.tsx` | "People" section group icon |
|
||||
| `CalendarBlank` | `calendar` | `Sidebar.tsx` | "Events" section group icon |
|
||||
| `Tag` | `tag` | `Sidebar.tsx` | "Topics" section group icon |
|
||||
| `TagSimple` | `tag` | `Sidebar.tsx` | "Untagged" nav item icon |
|
||||
| `Trash` | `trash` | `Sidebar.tsx` | "Trash" nav item icon |
|
||||
| `Plus` | `plus` | `NoteList.tsx`, `Editor.tsx` | Create note button, new tab button |
|
||||
| `Columns` | `rectangle.split.2x1` | `Editor.tsx` | Split view button (disabled placeholder) |
|
||||
| `ArrowsOutSimple` | `arrow.up.left.and.arrow.down.right` | `Editor.tsx` | Expand/focus button (disabled placeholder) |
|
||||
| `GitBranch` | `arrow.triangle.branch` | `Editor.tsx` | Version history button (disabled placeholder) |
|
||||
| `CursorText` | `character.cursor.ibeam` | `Editor.tsx` | Focus mode button (disabled placeholder) |
|
||||
| `Sparkle` | `sparkles` | `Editor.tsx` | AI assist button (disabled placeholder) |
|
||||
| `DotsThree` | `ellipsis` | `Editor.tsx` | More options button (disabled placeholder) |
|
||||
| `SlidersHorizontal` | `slider.horizontal.3` | `Inspector.tsx` | Inspector header icon |
|
||||
| `X` (Phosphor) | `xmark` | `Inspector.tsx` | Inspector close button |
|
||||
| `IconProps` (type) | n/a | `Sidebar.tsx` | TypeScript type for icon component props |
|
||||
|
||||
---
|
||||
|
||||
## Lucide React — Current Usage
|
||||
|
||||
### App Components
|
||||
|
||||
These Lucide icons are used in custom app components. Some may migrate to SF Symbols; others are kept for specific reasons.
|
||||
|
||||
| Lucide Icon | SF Symbol Equivalent | File | Usage | Migration Notes |
|
||||
|---|---|---|---|---|
|
||||
| `ChevronRight` | `chevron.right` | `Sidebar.tsx` | Section group expand chevron | Keep Lucide or migrate — small utility icon |
|
||||
| `ChevronDown` | `chevron.down` | `Sidebar.tsx` | Section group collapse chevron | Keep Lucide or migrate — small utility icon |
|
||||
| `GitCommitHorizontal` | `circle.dotted` | `Sidebar.tsx` | Commit & Push button icon | Keep Lucide or migrate |
|
||||
| `X` (Lucide) | `xmark` | `Editor.tsx` | Tab close button | Keep Lucide or migrate |
|
||||
| `Package` | `shippingbox` | `StatusBar.tsx` | App version indicator | Keep Lucide — status bar uses Lucide per design |
|
||||
| `GitBranch` (Lucide) | `arrow.triangle.branch` | `StatusBar.tsx` | Git branch indicator | Keep Lucide — status bar uses Lucide per design |
|
||||
| `RefreshCw` | `arrow.clockwise` | `StatusBar.tsx` | Sync status indicator | Keep Lucide — status bar uses Lucide per design |
|
||||
| `Sparkles` (Lucide) | `sparkles` | `StatusBar.tsx` | AI model indicator | Keep Lucide — status bar uses Lucide per design |
|
||||
| `FileText` (Lucide) | `doc.text` | `StatusBar.tsx` | Notes count indicator | Keep Lucide — status bar uses Lucide per design |
|
||||
| `Bell` | `bell` | `StatusBar.tsx` | Notifications (disabled placeholder) | Keep Lucide — status bar uses Lucide per design |
|
||||
| `Settings` | `gearshape` | `StatusBar.tsx` | Settings (disabled placeholder) | Keep Lucide — status bar uses Lucide per design |
|
||||
|
||||
### shadcn/ui Components (Keep Lucide)
|
||||
|
||||
These are standard shadcn/ui library components that use Lucide as their built-in icon system. These should **not** be migrated — they are part of the component library's internal implementation.
|
||||
|
||||
| Lucide Icon | File | Usage |
|
||||
|---|---|---|
|
||||
| `CheckIcon` | `ui/select.tsx` | Selected item indicator |
|
||||
| `ChevronDownIcon` | `ui/select.tsx` | Select trigger arrow, scroll-down button |
|
||||
| `ChevronUpIcon` | `ui/select.tsx` | Scroll-up button |
|
||||
| `CheckIcon` | `ui/dropdown-menu.tsx` | Checkbox item indicator |
|
||||
| `ChevronRightIcon` | `ui/dropdown-menu.tsx` | Sub-menu trigger arrow |
|
||||
| `CircleIcon` | `ui/dropdown-menu.tsx` | Radio item indicator |
|
||||
| `XIcon` | `ui/dialog.tsx` | Dialog close button |
|
||||
|
||||
---
|
||||
|
||||
## Approach Options for SF Symbols in React/Tauri
|
||||
|
||||
### Option 1: `sf-symbols-react` npm package
|
||||
- **Pros**: Drop-in React components, familiar API (`<SFSymbol name="magnifyingglass" />`)
|
||||
- **Cons**: Third-party package, may lag behind Apple's symbol updates, limited weight/rendering options
|
||||
- **Status**: Check npm for current maintenance state before adopting
|
||||
|
||||
### Option 2: SVG extraction from SF Symbols app
|
||||
- **Pros**: Exact Apple-quality vectors, no runtime dependency, full control over styling
|
||||
- **Cons**: Manual export process per icon, potential licensing concerns (SF Symbols license restricts use to Apple platforms), need to manage SVG sprite or individual files
|
||||
- **How**: Export SVGs from the SF Symbols macOS app, create a `src/icons/` directory with individual SVG components or a sprite sheet
|
||||
|
||||
### Option 3: Apple's SF Symbols font (native approach via Tauri)
|
||||
- **Pros**: Pixel-perfect on macOS, automatic weight matching, system-native feel
|
||||
- **Cons**: Only works on macOS (not cross-platform), requires Tauri native font access, won't render in browser dev mode
|
||||
- **How**: Use CSS `font-family: "SF Pro"` with Unicode code points, or invoke native APIs from Tauri's Rust backend
|
||||
|
||||
### Option 4: Hybrid — SVG in browser, native in Tauri
|
||||
- **Pros**: Best of both worlds — browser dev mode uses SVGs, production Tauri build uses native SF Symbols
|
||||
- **Cons**: More complex build setup, need to maintain two icon systems
|
||||
- **How**: Build an `<Icon>` wrapper component that checks `window.__TAURI__` and renders native or SVG accordingly
|
||||
|
||||
### Recommendation
|
||||
**Option 2 (SVG extraction)** is the most practical starting point:
|
||||
- Laputa is a macOS-only Tauri app, so SF Symbols licensing applies (Apple platform)
|
||||
- SVGs work in both browser dev mode and Tauri production
|
||||
- No third-party dependency to maintain
|
||||
- Can later upgrade to Option 4 (hybrid native) for perfect macOS integration
|
||||
|
||||
---
|
||||
|
||||
## Migration Steps (Future)
|
||||
|
||||
1. Export all needed SF Symbol SVGs from the SF Symbols macOS app
|
||||
2. Create `src/icons/sf-symbols/` with a React component per icon (or a single sprite)
|
||||
3. Build a thin `<SFIcon name="..." size={} />` wrapper for consistent API
|
||||
4. Replace Phosphor imports file-by-file (Sidebar → NoteList → Editor → Inspector)
|
||||
5. Decide whether to also replace Lucide in StatusBar and utility icons (chevrons, X)
|
||||
6. Keep Lucide in shadcn/ui components — do not modify those
|
||||
7. Once all Phosphor icons are replaced, remove `@phosphor-icons/react` from dependencies
|
||||
8. Run `pnpm build` and visually verify all icons render correctly
|
||||
53
VISION.md
53
VISION.md
@@ -1,53 +0,0 @@
|
||||
# Laputa — Vision
|
||||
|
||||
Laputa is a personal knowledge base where humans and AI agents collaborate as equals.
|
||||
|
||||
---
|
||||
|
||||
## Core principles
|
||||
|
||||
### 1. The vault is the source of truth
|
||||
Everything lives in the vault as plain text files. Notes, relations, configuration, instructions — all Markdown with YAML frontmatter. No proprietary database, no hidden state. If you can open a terminal, you can read your vault. If you can write Markdown, you can modify it.
|
||||
|
||||
### 2. Vault-native configuration
|
||||
Laputa configures itself through files inside the vault — the same files you write and read every day. There is no separate "settings app" or admin panel. If you want to change a theme, you edit a note. If you want to give instructions to an AI agent, you write a note. If you want to define a template, you create a note.
|
||||
|
||||
This applies to:
|
||||
- **`_themes/`** — themes as notes with a YAML block in the body. Edit `_themes/dark.md`, see the colors change in real time.
|
||||
- **`AGENTS.md`** — instructions for AI agents. Write what you want them to know about your vault in plain language. They read it before acting.
|
||||
- **`_templates/`** — note templates per type. Create `_templates/event.md` and every new event starts from that structure.
|
||||
- **`_procedures/`** — recurring tasks as notes with a `schedule` frontmatter field.
|
||||
|
||||
The principle: **if it can be expressed in frontmatter + Markdown, it doesn't need a UI**.
|
||||
|
||||
### 3. Structure through types, not folders
|
||||
Notes have a `type` field. Types determine folders, icons, and colors — but the structure is defined by the data, not the filesystem hierarchy. You can query "all events in February" without knowing anything about folder layout.
|
||||
|
||||
Relations between notes are expressed as frontmatter arrays: `people: [Marco, Sara]`. A wikilink `[[Marco]]` in the body navigates to the person note. The graph emerges from the data, not from a separate graph database.
|
||||
|
||||
### 4. The file is the interface
|
||||
You can use Laputa's UI, or you can open a terminal. Or a text editor. Or Claude Code. They all operate on the same files. There is no difference between "the app" and "the vault" — the vault is the app.
|
||||
|
||||
This is why Laputa has an MCP server: external agents get the same tools the in-app AI panel uses. The interface is a convenience, not a requirement.
|
||||
|
||||
### 5. Humans and AI as collaborators
|
||||
Pulse — the activity feed — shows the history of the vault without distinguishing between human commits and agent commits. That's intentional. Laputa is designed to be a space where you and your AI agents work together, each contributing to the same knowledge base.
|
||||
|
||||
The AI doesn't have a separate workspace. It works in yours.
|
||||
|
||||
---
|
||||
|
||||
## What Laputa is not
|
||||
|
||||
- Not a todo app (though you can use it as one)
|
||||
- Not a note-taking app that syncs to the cloud (the vault is yours, sync however you want — git, iCloud, rsync)
|
||||
- Not a replacement for a terminal (power users will use both)
|
||||
- Not trying to abstract away git (git is a feature, not an implementation detail)
|
||||
|
||||
---
|
||||
|
||||
## The long game
|
||||
|
||||
A vault that grows with you for years. Events, people, projects, thoughts — all interconnected, all version-controlled, all accessible to any tool that can read a file.
|
||||
|
||||
Ten years from now, your vault should still be readable. Plain text is forever.
|
||||
Binary file not shown.
@@ -1,41 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze which broken links are to existing vs non-existent notes."""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '/Users/luca/Workspace/laputa-app')
|
||||
from select_demo_notes import build_graph, select_notes, LAPUTA_ROOT
|
||||
|
||||
nodes, link_lookup = build_graph()
|
||||
selected = select_notes(nodes, link_lookup, target_count=1000)
|
||||
|
||||
print("\n🔍 Analyzing broken links...")
|
||||
|
||||
# Count links by type
|
||||
total_outlinks = 0
|
||||
resolved = 0
|
||||
unresolved_but_exists = 0
|
||||
unresolved_not_exists = 0
|
||||
|
||||
for path in selected:
|
||||
node = nodes[path]
|
||||
for link_ref in node['outlinks']:
|
||||
total_outlinks += 1
|
||||
|
||||
if link_ref in link_lookup:
|
||||
target = link_lookup[link_ref]
|
||||
if target in selected:
|
||||
resolved += 1
|
||||
else:
|
||||
# Exists but not in selection
|
||||
unresolved_but_exists += 1
|
||||
else:
|
||||
# Doesn't exist at all
|
||||
unresolved_not_exists += 1
|
||||
|
||||
print(f"Total outlinks: {total_outlinks}")
|
||||
print(f" Resolved (in selection): {resolved} ({resolved/total_outlinks*100:.1f}%)")
|
||||
print(f" Unresolved but note exists: {unresolved_but_exists} ({unresolved_but_exists/total_outlinks*100:.1f}%)")
|
||||
print(f" Unresolved - note doesn't exist: {unresolved_not_exists} ({unresolved_not_exists/total_outlinks*100:.1f}%)")
|
||||
|
||||
print(f"\nIf we include ALL existing notes (not just selected):")
|
||||
print(f" Max possible resolution: {(resolved + unresolved_but_exists)/total_outlinks*100:.1f}%")
|
||||
7
demo-vault-v2/area/refactoring.md
Normal file
7
demo-vault-v2/area/refactoring.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
Is A: Area
|
||||
Status: Active
|
||||
---
|
||||
# Refactoring
|
||||
|
||||
Area note covering refactoring practices, principles, and techniques for improving code quality without changing external behavior.
|
||||
6
demo-vault-v2/note/refactoring-ideas.md
Normal file
6
demo-vault-v2/note/refactoring-ideas.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
Is A: Note
|
||||
---
|
||||
# Refactoring Ideas
|
||||
|
||||
Collection of ideas for refactoring the codebase to improve maintainability and performance.
|
||||
6
demo-vault-v2/note/refactoring-key-ideas.md
Normal file
6
demo-vault-v2/note/refactoring-key-ideas.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
Is A: Note
|
||||
---
|
||||
# Refactoring Key Ideas
|
||||
|
||||
Key takeaways from Martin Fowler's Refactoring book and other refactoring resources.
|
||||
6
demo-vault-v2/note/refactoring-patterns.md
Normal file
6
demo-vault-v2/note/refactoring-patterns.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
Is A: Note
|
||||
---
|
||||
# Refactoring Patterns
|
||||
|
||||
Common refactoring patterns including Extract Method, Rename Variable, and Replace Conditional with Polymorphism.
|
||||
1
design/trash-management.pen
Normal file
1
design/trash-management.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -2,36 +2,119 @@
|
||||
|
||||
Key abstractions and domain models in Laputa.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Laputa's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
|
||||
|
||||
The full set of design principles is documented in [ARCHITECTURE.md](./ARCHITECTURE.md#design-principles).
|
||||
|
||||
## Semantic Field Names (conventions)
|
||||
|
||||
These frontmatter field names have special meaning in Laputa's UI:
|
||||
|
||||
| Field | Meaning | UI behavior |
|
||||
|---|---|---|
|
||||
| `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping |
|
||||
| `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header |
|
||||
| `url:` | External link | Clickable link chip in editor header |
|
||||
| `date:` | Single date | Formatted date badge |
|
||||
| `start_date:` + `end_date:` | Duration/timespan | Date range badge |
|
||||
| `goal:` + `result:` | Progress | Progress indicator in editor header |
|
||||
| `Workspace:` | Vault context filter | Global workspace filter |
|
||||
| `Belongs to:` | Parent relationship | Relationship chip in Properties panel |
|
||||
| `Related to:` | Lateral relationship | Relationship chip in Properties panel |
|
||||
|
||||
The list of default-shown relationships and semantic property rendering rules can be customized via `config/relations.md` and `config/semantic-properties.md` in the vault.
|
||||
|
||||
## Document Model
|
||||
|
||||
All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth.
|
||||
|
||||
### VaultEntry
|
||||
|
||||
The core data type representing a single note, defined identically in Rust (`src-tauri/src/vault.rs`) and TypeScript (`src/types.ts`):
|
||||
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`).
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class VaultEntry {
|
||||
+String path
|
||||
+String filename
|
||||
+String title
|
||||
+String? isA
|
||||
+String[] aliases
|
||||
+String[] belongsTo
|
||||
+String[] relatedTo
|
||||
+Record~string,string[]~ relationships
|
||||
+String[] outgoingLinks
|
||||
+String? status
|
||||
+String? owner
|
||||
+Number? modifiedAt
|
||||
+Number? createdAt
|
||||
+Number wordCount
|
||||
+String? snippet
|
||||
+Boolean archived
|
||||
+Boolean trashed
|
||||
+Number? trashedAt
|
||||
+Record~string,string~ properties
|
||||
}
|
||||
|
||||
class TypeDocument {
|
||||
+String icon
|
||||
+String color
|
||||
+Number order
|
||||
+String sidebarLabel
|
||||
+String template
|
||||
+String sort
|
||||
+Boolean visible
|
||||
}
|
||||
|
||||
class Frontmatter {
|
||||
+String type
|
||||
+String status
|
||||
+String url
|
||||
+String[] belongsTo
|
||||
+String[] relatedTo
|
||||
+String[] aliases
|
||||
...custom fields
|
||||
}
|
||||
|
||||
VaultEntry --> Frontmatter : parsed from
|
||||
VaultEntry --> TypeDocument : isA resolves to
|
||||
VaultEntry "many" --> "1" TypeDocument : grouped by type
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/types.ts
|
||||
interface VaultEntry {
|
||||
path: string // Absolute file path: /Users/luca/Laputa/project/my-project.md
|
||||
filename: string // Just the filename: my-project.md
|
||||
title: string // Extracted from first # heading, or filename as fallback
|
||||
isA: string | null // Entity type: Project, Procedure, Person, etc.
|
||||
aliases: string[] // Alternative names for wikilink resolution
|
||||
belongsTo: string[] // Parent relationships (wikilinks)
|
||||
relatedTo: string[] // Related entity links (wikilinks)
|
||||
status: string | null // Active, Done, Paused, Archived, Dropped
|
||||
owner: string | null // Person responsible
|
||||
cadence: string | null // Update frequency: Weekly, Monthly, etc.
|
||||
path: string // Absolute file path
|
||||
filename: string // Just the filename
|
||||
title: string // From first # heading, or filename fallback
|
||||
isA: string | null // Entity type: Project, Procedure, Person, etc. (from frontmatter `type:` field)
|
||||
aliases: string[] // Alternative names for wikilink resolution
|
||||
belongsTo: string[] // Parent relationships (wikilinks)
|
||||
relatedTo: string[] // Related entity links (wikilinks)
|
||||
relationships: Record<string, string[]> // All frontmatter fields containing wikilinks
|
||||
outgoingLinks: string[] // All [[wikilinks]] found in note body
|
||||
status: string | null // Active, Done, Paused, Archived, Dropped
|
||||
owner: string | null // Person responsible
|
||||
cadence: string | null // Update frequency: Weekly, Monthly, etc.
|
||||
modifiedAt: number | null // Unix timestamp (seconds)
|
||||
createdAt: number | null // Unix timestamp (seconds)
|
||||
fileSize: number
|
||||
wordCount: number | null // Body word count (excludes frontmatter)
|
||||
snippet: string | null // First 200 chars of body
|
||||
archived: boolean // Archived flag
|
||||
trashed: boolean // Trashed flag
|
||||
trashedAt: number | null // When trashed (for auto-purge)
|
||||
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
|
||||
}
|
||||
```
|
||||
|
||||
### Entity Types (isA)
|
||||
### Entity Types (isA / type)
|
||||
|
||||
Entity type is inferred from the folder structure. The vault is organized by type:
|
||||
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
|
||||
|
||||
Type is also inferred from the folder structure when `type:` is absent. The vault is organized by type:
|
||||
|
||||
```
|
||||
~/Laputa/
|
||||
@@ -47,26 +130,41 @@ Entity type is inferred from the folder structure. The vault is organized by typ
|
||||
├── quarter/ → "Quarter"
|
||||
├── journal/ → "Journal"
|
||||
├── essay/ → "Essay"
|
||||
└── evergreen/ → "Evergreen"
|
||||
├── evergreen/ → "Evergreen"
|
||||
├── theme/ → "Theme" ← vault-based themes
|
||||
└── config/ → "Config" ← meta-configuration files (agents.md, etc.)
|
||||
```
|
||||
|
||||
Mapping logic lives in `vault.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
|
||||
Mapping logic lives in `vault/mod.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
|
||||
|
||||
### Types as Files
|
||||
|
||||
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
|
||||
|
||||
- Have `Is A: Type` in their frontmatter
|
||||
- Describe what the type means, its expected properties, and how it relates to other types
|
||||
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any other note
|
||||
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
|
||||
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
|
||||
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
|
||||
- Serve as the "definition" for their type category
|
||||
|
||||
**Type document properties** (read by Rust and used in the UI):
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `icon` | string | Phosphor icon name (kebab-case, e.g., "cooking-pot") |
|
||||
| `color` | string | Accent color: red, purple, blue, green, yellow, orange |
|
||||
| `order` | number | Sidebar display order (lower = higher priority) |
|
||||
| `sidebar_label` | string | Custom label overriding auto-pluralization |
|
||||
| `template` | string | Markdown template for new notes of this type |
|
||||
| `sort` | string | Default sort: "modified:desc", "title:asc", "property:Priority:asc" |
|
||||
| `view` | string | Default view mode: "all", "editor-list", "editor-only" |
|
||||
| `visible` | bool | Whether type appears in sidebar (default: true) |
|
||||
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.
|
||||
|
||||
**UI behavior**:
|
||||
- Clicking a section group header (e.g., "Projects") pins the type document at the top of the NoteList if it exists, with instances listed below
|
||||
- Clicking a section group header pins the type document at the top of the NoteList if it exists
|
||||
- Viewing a type document in entity view shows an "Instances" group listing all entries of that type
|
||||
- The Type field in the Inspector properties panel is rendered as a clickable chip that navigates to the type document
|
||||
- The Type field in the Inspector is rendered as a clickable chip that navigates to the type document
|
||||
|
||||
### Frontmatter Format
|
||||
|
||||
@@ -88,24 +186,48 @@ aliases:
|
||||
---
|
||||
```
|
||||
|
||||
Supported value types (defined in `src-tauri/src/frontmatter.rs` as `FrontmatterValue`):
|
||||
Supported value types (defined in `src-tauri/src/frontmatter/yaml.rs` as `FrontmatterValue`):
|
||||
- **String**: `status: Active`
|
||||
- **Number**: `priority: 5`
|
||||
- **Bool**: `archived: true`
|
||||
- **List**: Multi-line ` - item` or inline `[item1, item2]`
|
||||
- **Null**: `owner:` (empty value)
|
||||
|
||||
### Custom Relationships
|
||||
|
||||
The Rust parser scans all frontmatter keys for fields containing `[[wikilinks]]`. Any non-standard field with wikilink values is captured in the `relationships` HashMap:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Topics:
|
||||
- "[[topic/writing]]"
|
||||
- "[[topic/productivity]]"
|
||||
Key People:
|
||||
- "[[person/matteo-cellini]]"
|
||||
---
|
||||
```
|
||||
|
||||
Becomes: `relationships["Topics"] = ["[[topic/writing]]", "[[topic/productivity]]"]`
|
||||
|
||||
This enables arbitrary, extensible relationship types without code changes.
|
||||
|
||||
### Outgoing Links
|
||||
|
||||
All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex and stored in `outgoingLinks`. Used for backlink detection and relationship graphs.
|
||||
|
||||
### Title Extraction
|
||||
|
||||
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. This logic lives in `vault.rs:extract_title()`.
|
||||
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. Logic in `vault/parsing.rs:extract_title()`.
|
||||
|
||||
### Sidebar Selection
|
||||
|
||||
Navigation state is modeled as a discriminated union:
|
||||
|
||||
```typescript
|
||||
type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
|
||||
type SidebarSelection =
|
||||
| { kind: 'filter'; filter: 'all' | 'favorites' }
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
| { kind: 'sectionGroup'; type: string } // e.g. type: 'Project'
|
||||
| { kind: 'entity'; entry: VaultEntry } // specific entity selected
|
||||
| { kind: 'topic'; entry: VaultEntry } // topic selected
|
||||
@@ -115,49 +237,61 @@ type SidebarSelection =
|
||||
|
||||
### Vault Scanning (Rust)
|
||||
|
||||
`vault::scan_vault(path)` in `src-tauri/src/vault.rs`:
|
||||
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
|
||||
|
||||
1. Validates the path exists and is a directory
|
||||
2. Uses `walkdir` to recursively traverse the directory (follows symlinks)
|
||||
2. Uses `walkdir` to recursively traverse (follows symlinks)
|
||||
3. Filters to `.md` files only
|
||||
4. For each file, calls `parse_md_file()`:
|
||||
- Reads file content with `fs::read_to_string()`
|
||||
- Reads content with `fs::read_to_string()`
|
||||
- Parses frontmatter with `gray_matter::Matter::<YAML>`
|
||||
- Extracts title from first `#` heading
|
||||
- Infers entity type from parent folder name
|
||||
- Parses dates (`created_at`, `created_time`) as ISO 8601 to Unix timestamps
|
||||
- Collects file metadata (size, modification time)
|
||||
5. Sorts results by `modified_at` descending (newest first)
|
||||
- Infers entity type from parent folder name (or explicit `type:` frontmatter; `Is A:` accepted as legacy alias)
|
||||
- Parses dates as ISO 8601 to Unix timestamps
|
||||
- Extracts relationships, outgoing links, custom properties, word count, snippet
|
||||
5. Sorts by `modified_at` descending
|
||||
6. Skips unparseable files with a warning log
|
||||
|
||||
### Vault Caching
|
||||
|
||||
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
|
||||
|
||||
1. Reads cache from `~/.laputa/cache/<vault-hash>.json` (external to vault)
|
||||
2. Compares cache version, vault path, and git HEAD commit hash
|
||||
3. If cache is valid and same commit → only re-parse uncommitted changed files
|
||||
4. If different commit → use `git diff` to find changed files → selective re-parse
|
||||
5. If no cache → full scan
|
||||
6. Writes updated cache atomically (write to `.tmp`, then rename)
|
||||
7. On first run, migrates any legacy `.laputa-cache.json` from inside the vault
|
||||
|
||||
### Frontmatter Manipulation (Rust)
|
||||
|
||||
`frontmatter::update_frontmatter_content()` in `src-tauri/src/frontmatter.rs` performs line-by-line YAML editing:
|
||||
`frontmatter/ops.rs:update_frontmatter_content()` performs line-by-line YAML editing:
|
||||
|
||||
1. Finds the frontmatter block between `---` delimiters
|
||||
2. Iterates through lines looking for the target key (handles quoted keys like `"Is A"`)
|
||||
2. Iterates through lines looking for the target key
|
||||
3. If found: replaces the value (consuming multi-line list items if present)
|
||||
4. If not found: appends the new key-value at the end of the frontmatter
|
||||
5. If no frontmatter exists: creates a new `---` block with the key-value
|
||||
4. If not found: appends the new key-value at the end
|
||||
5. If no frontmatter exists: creates a new `---` block
|
||||
|
||||
The `with_frontmatter()` helper wraps this in a read-transform-write cycle on the actual file.
|
||||
|
||||
### Content Loading
|
||||
|
||||
- **Tauri mode**: Content is loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
|
||||
- **Browser mode**: All content is loaded at startup from `MOCK_CONTENT` in `mock-tauri.ts`
|
||||
- **Tauri mode**: Content loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
|
||||
- **Browser mode**: All content loaded at startup from mock data
|
||||
- Content for backlink detection (`allContent`) is stored in memory as `Record<string, string>`
|
||||
|
||||
## Git Integration
|
||||
|
||||
Git operations live in `src-tauri/src/git.rs`. All operations shell out to the `git` CLI (not libgit2).
|
||||
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2).
|
||||
|
||||
### Data Types
|
||||
|
||||
```typescript
|
||||
interface GitCommit {
|
||||
hash: string // Full SHA-1
|
||||
shortHash: string // First 7 chars
|
||||
hash: string
|
||||
shortHash: string
|
||||
message: string
|
||||
author: string
|
||||
date: number // Unix timestamp
|
||||
@@ -168,32 +302,54 @@ interface ModifiedFile {
|
||||
relativePath: string // Relative to vault root
|
||||
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
|
||||
}
|
||||
|
||||
interface PulseCommit {
|
||||
hash: string
|
||||
shortHash: string
|
||||
message: string
|
||||
date: number
|
||||
githubUrl: string | null
|
||||
files: PulseFile[]
|
||||
added: number
|
||||
modified: number
|
||||
deleted: number
|
||||
}
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation | Git command | Notes |
|
||||
|-----------|------------|-------|
|
||||
| File history | `git log --format=%H\|%h\|%an\|%aI\|%s -n 20 -- <file>` | Last 20 commits for a file |
|
||||
| Modified files | `git status --porcelain` | Filtered to `.md` files only |
|
||||
| File diff | `git diff -- <file>`, fallback to `--cached`, then synthetic diff for untracked | Unified diff format |
|
||||
| Commit | `git add -A && git commit -m "<message>"` | Stages all changes |
|
||||
| Push | `git push` | Pushes to upstream of current branch |
|
||||
| Module | Operation | Notes |
|
||||
|--------|-----------|-------|
|
||||
| `history.rs` | File history | `git log` — last 20 commits per file |
|
||||
| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` |
|
||||
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
|
||||
| `commit.rs` | Commit | `git add -A && git commit -m "..."` |
|
||||
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
|
||||
| `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual |
|
||||
| `pulse.rs` | Activity feed | `git log` with `--name-status` for file changes |
|
||||
|
||||
### Auto-Sync
|
||||
|
||||
`useAutoSync` hook handles automatic git sync:
|
||||
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
|
||||
- Pulls on interval, pushes after commits
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
- **Modified file badges**: Loaded at startup, shown in sidebar and breadcrumb bar
|
||||
- **Diff view**: Loaded on-demand when user clicks the diff toggle in the breadcrumb bar
|
||||
- **Git history**: Loaded when active tab changes, shown in Inspector panel
|
||||
- **Commit dialog**: Triggered from sidebar, runs commit + push
|
||||
- **Modified file badges**: Orange dots in sidebar and tab bar
|
||||
- **Diff view**: Toggle in breadcrumb bar → shows unified diff
|
||||
- **Git history**: Shown in Inspector panel for active note
|
||||
- **Commit dialog**: Triggered from sidebar or Cmd+K
|
||||
- **Pulse view**: Activity feed when Pulse filter is selected
|
||||
|
||||
## BlockNote Customization
|
||||
|
||||
The editor uses [BlockNote](https://www.blocknotejs.org/) (not CodeMirror 6) for rich text editing.
|
||||
The editor uses [BlockNote](https://www.blocknotejs.org/) for rich text editing, with CodeMirror 6 available as a raw editing alternative.
|
||||
|
||||
### Custom Wikilink Inline Content
|
||||
|
||||
Defined in `src/components/Editor.tsx`:
|
||||
Defined in `src/components/editorSchema.tsx`:
|
||||
|
||||
```typescript
|
||||
const WikiLink = createReactInlineContentSpec(
|
||||
@@ -202,69 +358,206 @@ const WikiLink = createReactInlineContentSpec(
|
||||
propSchema: { target: { default: "" } },
|
||||
content: "none",
|
||||
},
|
||||
{
|
||||
render: (props) => (
|
||||
<span className="wikilink" data-target={props.inlineContent.props.target}>
|
||||
{props.inlineContent.props.target}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
{ render: (props) => <span className="wikilink">...</span> }
|
||||
)
|
||||
|
||||
const schema = BlockNoteSchema.create({
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
wikilink: WikiLink,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Markdown-to-BlockNote Pipeline
|
||||
|
||||
Since BlockNote doesn't natively understand `[[wikilinks]]`, content goes through a preprocessing pipeline in `src/utils/wikilinks.ts`:
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
|
||||
B --> C["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
C --> D["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
D --> E["injectWikilinks(blocks)\n‹token› → WikiLink node"]
|
||||
E --> F["editor.replaceBlocks()\n→ rendered editor"]
|
||||
|
||||
```
|
||||
Raw markdown
|
||||
→ splitFrontmatter() → [yaml, body]
|
||||
→ preProcessWikilinks(body) → replaces [[target]] with Unicode placeholder tokens
|
||||
→ editor.tryParseMarkdownToBlocks() → BlockNote block tree
|
||||
→ injectWikilinks(blocks) → walks tree, replaces placeholder text with wikilink inline content nodes
|
||||
→ editor.replaceBlocks()
|
||||
style A fill:#f8f9fa,stroke:#6c757d,color:#000
|
||||
style F fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
Placeholder tokens use `\u2039` (single left-pointing angle quotation mark) and `\u203A` (single right-pointing) to avoid colliding with markdown syntax.
|
||||
> Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
|
||||
B --> C["postProcessWikilinks()\nWikiLink node → [[target]]"]
|
||||
C --> D["prepend frontmatter yaml"]
|
||||
D --> E["invoke('save_note_content')\n→ disk write"]
|
||||
|
||||
style A fill:#cce5ff,stroke:#004085,color:#000
|
||||
style E fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
### Wikilink Navigation
|
||||
|
||||
Two navigation mechanisms:
|
||||
|
||||
1. **Click handler**: A DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements and calls `onNavigateWikilink(target)`.
|
||||
1. **Click handler**: DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements → `onNavigateWikilink(target)`.
|
||||
2. **Suggestion menu**: Typing `[[` triggers `SuggestionMenuController` with filtered vault entries.
|
||||
|
||||
2. **Suggestion menu**: Typing `[[` triggers BlockNote's `SuggestionMenuController`, which shows a filtered list of all vault entries. Selecting one inserts a wikilink inline content node.
|
||||
Wikilink resolution (`useNoteActions`) uses fuzzy matching: exact title → alias → path stem → filename stem → slug-to-words.
|
||||
|
||||
Wikilink resolution in `useNoteActions.handleNavigateWikilink()` uses fuzzy matching:
|
||||
- Exact title match
|
||||
- Alias match
|
||||
- Path stem match (e.g., `person/matteo-cellini`)
|
||||
- Filename stem match
|
||||
- Slug-to-words match (e.g., `matteo-cellini` → `matteo cellini`)
|
||||
### Raw Editor Mode
|
||||
|
||||
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
|
||||
|
||||
## Theme System
|
||||
|
||||
See [THEMING.md](./THEMING.md) for the full theme system documentation.
|
||||
|
||||
In brief: `src/theme.json` defines editor typography and styling as nested JSON. The `useEditorTheme` hook flattens it into CSS custom properties that are applied as inline styles on the BlockNote container.
|
||||
### Overview
|
||||
|
||||
Two-layer theming:
|
||||
1. **Global CSS variables** (`src/index.css`): App-wide colors via `:root`, bridged to Tailwind v4
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
|
||||
|
||||
### Vault-Based Themes
|
||||
|
||||
Themes are markdown notes in `theme/` with `type: Theme` frontmatter. Each property becomes a CSS variable with `--` prefix.
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Theme
|
||||
Description: Light theme with warm, paper-like tones
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
accent-blue: "#155DFF"
|
||||
editor-font-size: 16
|
||||
editor-line-height: 1.5
|
||||
---
|
||||
```
|
||||
|
||||
### ThemeManager
|
||||
|
||||
`useThemeManager` hook manages the theme lifecycle:
|
||||
|
||||
```typescript
|
||||
interface ThemeManager {
|
||||
themes: ThemeFile[]
|
||||
activeThemeId: string | null
|
||||
activeTheme: ThemeFile | null
|
||||
isDark: boolean
|
||||
switchTheme(themeId: string): Promise<void>
|
||||
createTheme(name?: string): Promise<string>
|
||||
reloadThemes(): Promise<void>
|
||||
updateThemeProperty(key: string, value: string): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- Detects dark backgrounds via luminance calculation → sets `color-scheme` and `data-theme-mode`
|
||||
- Live preview: re-applies when active theme note is saved
|
||||
- Three built-in themes: Default (light), Dark (deep navy), Minimal (high contrast)
|
||||
- Legacy JSON themes (`_themes/*.json`) supported for backward compatibility
|
||||
|
||||
### Theme Property Editor
|
||||
|
||||
`ThemePropertyEditor` component provides an interactive UI for editing theme properties. Uses `themeSchema.ts` to determine input types (color picker, number slider, text field) based on property names and values.
|
||||
|
||||
## Inspector Abstraction
|
||||
|
||||
The Inspector panel (`src/components/Inspector.tsx`) is composed of four sub-panels:
|
||||
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs with two distinct sections:
|
||||
- **Editable properties** (top): frontmatter fields the user can modify — shown with interactive hover styling (`hover:bg-muted`), cursor pointer, and click-to-edit. Includes Type badge, Status pill, boolean toggles, array tag pills, and text fields.
|
||||
- **Info section** (bottom, separated by border): read-only derived metadata — Modified, Created, Words, File Size. Uses muted text color (`--text-muted`) with no hover states or click interaction. These fields are computed from file metadata and content, not from frontmatter.
|
||||
- Keys in `SKIP_KEYS` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section since they are either internal or already displayed elsewhere (e.g., `is_a` is shown via the TypeRow badge).
|
||||
2. **Relationships**: Shows `belongs_to` and `related_to` wikilinks as clickable chips.
|
||||
3. **Backlinks**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
|
||||
4. **Git History**: Shows the last few commits from `gitHistory` state.
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
|
||||
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
|
||||
Frontmatter parsing on the TypeScript side is handled by `src/utils/frontmatter.ts:parseFrontmatter()`, a lightweight YAML parser that handles strings, booleans, inline arrays, and multi-line lists.
|
||||
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, and all custom relationship fields as clickable wikilink chips.
|
||||
|
||||
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
|
||||
|
||||
4. **GitHistoryPanel**: Shows recent commits from file history with relative timestamps.
|
||||
|
||||
## Closed Tab History
|
||||
|
||||
`useClosedTabHistory` hook (`src/hooks/useClosedTabHistory.ts`) provides a LIFO stack for closed tab entries, used by `useTabManagement` to support Cmd+Shift+T reopen. Each entry stores the note's path, tab index, and full `VaultEntry`. The stack is in-memory only (resets on restart), capped at 20 entries, and deduplicates by path.
|
||||
|
||||
## Search & Indexing
|
||||
|
||||
### Search Modes
|
||||
|
||||
```typescript
|
||||
type SearchMode = 'keyword' | 'semantic' | 'hybrid'
|
||||
|
||||
interface SearchResult {
|
||||
title: string
|
||||
path: string
|
||||
snippet: string
|
||||
score: number
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
results: SearchResult[]
|
||||
elapsedMs: number
|
||||
}
|
||||
```
|
||||
|
||||
### Search Integration
|
||||
|
||||
`SearchPanel` component provides the search UI:
|
||||
- Mode selector (keyword/semantic/hybrid)
|
||||
- Real-time results as user types
|
||||
- Click result to open note in editor
|
||||
- Shows relevance score and snippet
|
||||
|
||||
### Indexing
|
||||
|
||||
Managed by `useIndexing` hook:
|
||||
- Checks index status on vault load
|
||||
- Two-phase indexing: scanning (parse files) → embedding (generate vectors)
|
||||
- Progress streamed via Tauri events
|
||||
- Incremental updates after git sync
|
||||
- Metadata persisted in `.laputa-index.json`
|
||||
|
||||
## Vault Management
|
||||
|
||||
### Vault Switching
|
||||
|
||||
`useVaultSwitcher` hook manages multiple vaults:
|
||||
- Persists vault list to `~/.config/com.laputa.app/vaults.json`
|
||||
- Switching closes all tabs and resets sidebar
|
||||
- Supports adding, removing, hiding/restoring vaults
|
||||
- Default vault: Getting Started demo vault
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault settings stored in `config/ui.config.md`:
|
||||
- Editable as a normal note (YAML frontmatter)
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### Getting Started / Onboarding
|
||||
|
||||
`useOnboarding` hook detects first launch:
|
||||
- If vault path doesn't exist → show `WelcomeScreen`
|
||||
- User can create Getting Started vault or open existing folder
|
||||
- Welcome state tracked in localStorage (`laputa_welcome_dismissed`)
|
||||
|
||||
### GitHub Integration
|
||||
|
||||
Device Authorization Flow for GitHub-backed vaults:
|
||||
- `GitHubDeviceFlow` component handles OAuth
|
||||
- `GitHubVaultModal` for cloning existing repos or creating new ones
|
||||
- Token persisted in app settings for future git operations
|
||||
- `SettingsPanel` shows connection status with disconnect option
|
||||
|
||||
## Settings
|
||||
|
||||
App-level settings persisted at `~/.config/com.laputa.app/settings.json`:
|
||||
|
||||
```typescript
|
||||
interface Settings {
|
||||
anthropic_key: string | null
|
||||
openai_key: string | null
|
||||
google_key: string | null
|
||||
github_token: string | null
|
||||
github_username: string | null
|
||||
auto_pull_interval_minutes: number | null
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ How to navigate the codebase, run the app, and find what you need.
|
||||
- **Node.js** 18+ and **pnpm**
|
||||
- **Rust** 1.77.2+ (for the Tauri backend)
|
||||
- **git** CLI (required by the git integration features)
|
||||
- **qmd** (optional — for search indexing; auto-installed if missing)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -24,7 +25,7 @@ pnpm tauri dev
|
||||
# Run tests
|
||||
pnpm test # Vitest unit tests
|
||||
cargo test # Rust tests (from src-tauri/)
|
||||
pnpm test:e2e # Playwright E2E tests
|
||||
pnpm playwright:smoke # Playwright smoke tests
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
@@ -33,39 +34,94 @@ pnpm test:e2e # Playwright E2E tests
|
||||
laputa-app/
|
||||
├── src/ # React frontend
|
||||
│ ├── main.tsx # Entry point (renders <App />)
|
||||
│ ├── App.tsx # Root component — orchestrates 4-panel layout
|
||||
│ ├── App.tsx # Root component — orchestrates layout + state
|
||||
│ ├── App.css # App shell layout styles
|
||||
│ ├── types.ts # Shared TS types (VaultEntry, GitCommit, etc.)
|
||||
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
|
||||
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
|
||||
│ ├── theme.json # Editor theme configuration
|
||||
│ ├── index.css # Global CSS variables + Tailwind setup
|
||||
│ │
|
||||
│ ├── components/ # UI components
|
||||
│ │ ├── Sidebar.tsx # Left panel: filters + section groups
|
||||
│ ├── components/ # UI components (~98 files)
|
||||
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
|
||||
│ │ ├── SidebarParts.tsx # Sidebar subcomponents
|
||||
│ │ ├── NoteList.tsx # Second panel: filtered note list
|
||||
│ │ ├── Editor.tsx # Third panel: tabs + BlockNote + diff
|
||||
│ │ ├── NoteItem.tsx # Individual note item
|
||||
│ │ ├── PulseView.tsx # Git activity feed (replaces NoteList)
|
||||
│ │ ├── Editor.tsx # Third panel: tabs + editor orchestration
|
||||
│ │ ├── EditorContent.tsx # Editor content area
|
||||
│ │ ├── EditorRightPanel.tsx # Right panel toggle
|
||||
│ │ ├── editorSchema.tsx # BlockNote schema + wikilink type
|
||||
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
|
||||
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
|
||||
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
|
||||
│ │ ├── EditableValue.tsx # Inline value editor component
|
||||
│ │ ├── DiffView.tsx # Git diff viewer
|
||||
│ │ ├── ResizeHandle.tsx # Draggable panel divider
|
||||
│ │ ├── StatusBar.tsx # Bottom status bar
|
||||
│ │ ├── QuickOpenPalette.tsx # Cmd+P command palette
|
||||
│ │ ├── CreateNoteDialog.tsx # New note modal
|
||||
│ │ ├── AIChatPanel.tsx # AI chat (API-based)
|
||||
│ │ ├── AiPanel.tsx # AI agent (Claude CLI subprocess)
|
||||
│ │ ├── AiMessage.tsx # Agent message display
|
||||
│ │ ├── AiActionCard.tsx # Agent tool action cards
|
||||
│ │ ├── SearchPanel.tsx # Search interface
|
||||
│ │ ├── SettingsPanel.tsx # App settings
|
||||
│ │ ├── StatusBar.tsx # Bottom bar: vault picker + sync
|
||||
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
|
||||
│ │ ├── TabBar.tsx # Tab management
|
||||
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
|
||||
│ │ ├── WelcomeScreen.tsx # Onboarding screen
|
||||
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
|
||||
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
|
||||
│ │ ├── ThemePropertyEditor.tsx # Interactive theme editor
|
||||
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
|
||||
│ │ ├── CommitDialog.tsx # Git commit modal
|
||||
│ │ ├── Toast.tsx # Toast notifications
|
||||
│ │ ├── Editor.css # Editor layout styles
|
||||
│ │ ├── EditorTheme.css # BlockNote theme overrides
|
||||
│ │ └── ui/ # shadcn/ui primitives (button, dialog, etc.)
|
||||
│ │ ├── CreateNoteDialog.tsx # New note modal
|
||||
│ │ ├── CreateTypeDialog.tsx # New type modal
|
||||
│ │ ├── UpdateBanner.tsx # In-app update notification
|
||||
│ │ ├── inspector/ # Inspector sub-panels
|
||||
│ │ │ ├── BacklinksPanel.tsx
|
||||
│ │ │ ├── RelationshipsPanel.tsx
|
||||
│ │ │ ├── GitHistoryPanel.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ └── ui/ # shadcn/ui primitives
|
||||
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
|
||||
│ │
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── useVaultLoader.ts # Loads vault entries, git status, content
|
||||
│ │ ├── useNoteActions.ts # Tab management, frontmatter CRUD, navigation
|
||||
│ │ └── useTheme.ts # Flattens theme.json into CSS variables
|
||||
│ ├── hooks/ # Custom React hooks (~87 files)
|
||||
│ │ ├── useVaultLoader.ts # Loads vault entries + content
|
||||
│ │ ├── useVaultSwitcher.ts # Multi-vault management
|
||||
│ │ ├── useVaultConfig.ts # Per-vault UI settings
|
||||
│ │ ├── useNoteActions.ts # Tab management, navigation, CRUD
|
||||
│ │ ├── useTabManagement.ts # Tab ordering + lifecycle
|
||||
│ │ ├── useAIChat.ts # AI chat state
|
||||
│ │ ├── useAiAgent.ts # AI agent state + tool tracking
|
||||
│ │ ├── useAiActivity.ts # MCP UI bridge listener
|
||||
│ │ ├── useAutoSync.ts # Auto git pull/push
|
||||
│ │ ├── useConflictResolver.ts # Git conflict handling
|
||||
│ │ ├── useEditorSave.ts # Auto-save with debounce
|
||||
│ │ ├── useTheme.ts # Flatten theme.json → CSS vars
|
||||
│ │ ├── useThemeManager.ts # Vault theme lifecycle
|
||||
│ │ ├── useIndexing.ts # Search indexing management
|
||||
│ │ ├── useNoteSearch.ts # Note search
|
||||
│ │ ├── useCommandRegistry.ts # Command palette registry
|
||||
│ │ ├── useAppCommands.ts # App-level commands
|
||||
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
|
||||
│ │ ├── useSettings.ts # App settings
|
||||
│ │ ├── useOnboarding.ts # First-launch flow
|
||||
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
|
||||
│ │ ├── useMcpBridge.ts # MCP WebSocket client
|
||||
│ │ ├── useMcpStatus.ts # MCP registration status
|
||||
│ │ ├── useUpdater.ts # In-app updates
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── utils/ # Pure utility functions
|
||||
│ │ ├── frontmatter.ts # TypeScript YAML frontmatter parser
|
||||
│ │ └── wikilinks.ts # Wikilink preprocessing + word count
|
||||
│ ├── utils/ # Pure utility functions (~48 files)
|
||||
│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline
|
||||
│ │ ├── frontmatter.ts # TypeScript YAML parser
|
||||
│ │ ├── ai-agent.ts # Agent stream utilities
|
||||
│ │ ├── ai-chat.ts # Chat API client + token estimation
|
||||
│ │ ├── ai-context.ts # Context snapshot builder
|
||||
│ │ ├── noteListHelpers.ts # Sorting, filtering, date formatting
|
||||
│ │ ├── themeSchema.ts # Theme editor schema builder
|
||||
│ │ ├── configMigration.ts # localStorage → vault config migration
|
||||
│ │ ├── iconRegistry.ts # Phosphor icon registry
|
||||
│ │ ├── propertyTypes.ts # Property type definitions
|
||||
│ │ ├── vaultListStore.ts # Vault list persistence
|
||||
│ │ ├── vaultConfigStore.ts # Vault config store
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── lib/
|
||||
│ │ └── utils.ts # Tailwind merge + cn() helper
|
||||
@@ -78,28 +134,58 @@ laputa-app/
|
||||
│ ├── build.rs # Tauri build script
|
||||
│ ├── tauri.conf.json # Tauri app configuration
|
||||
│ ├── capabilities/ # Tauri v2 security capabilities
|
||||
│ │ └── default.json
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # Entry point (calls lib::run())
|
||||
│ │ ├── lib.rs # Tauri command registration (9 commands)
|
||||
│ │ ├── vault.rs # Vault scanning + markdown parsing
|
||||
│ │ ├── frontmatter.rs # YAML frontmatter manipulation
|
||||
│ │ └── git.rs # Git CLI operations
|
||||
│ │ ├── lib.rs # Tauri setup + command registration (61 commands)
|
||||
│ │ ├── commands.rs # All Tauri command handlers
|
||||
│ │ ├── vault/ # Vault module
|
||||
│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault
|
||||
│ │ │ ├── cache.rs # Git-based incremental caching
|
||||
│ │ │ ├── parsing.rs # Text processing + title extraction
|
||||
│ │ │ ├── trash.rs # Trash auto-purge
|
||||
│ │ │ ├── rename.rs # Rename + cross-vault wikilink update
|
||||
│ │ │ ├── image.rs # Image attachment saving
|
||||
│ │ │ ├── migration.rs # Frontmatter migration
|
||||
│ │ │ └── getting_started.rs # Getting Started vault creation
|
||||
│ │ ├── frontmatter/ # Frontmatter module
|
||||
│ │ │ ├── mod.rs, yaml.rs, ops.rs
|
||||
│ │ ├── git/ # Git module
|
||||
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs
|
||||
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
|
||||
│ │ ├── github/ # GitHub module
|
||||
│ │ │ ├── mod.rs, auth.rs, api.rs, clone.rs
|
||||
│ │ ├── theme/ # Theme module
|
||||
│ │ │ ├── mod.rs, create.rs, defaults.rs, seed.rs
|
||||
│ │ ├── search.rs # qmd search integration
|
||||
│ │ ├── indexing.rs # qmd indexing + progress streaming
|
||||
│ │ ├── claude_cli.rs # Claude CLI subprocess management
|
||||
│ │ ├── ai_chat.rs # Direct Anthropic API client
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + registration
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
│ │ ├── vault_config.rs # Per-vault UI config
|
||||
│ │ ├── vault_list.rs # Vault list persistence
|
||||
│ │ └── menu.rs # Native macOS menu bar
|
||||
│ └── icons/ # App icons
|
||||
│
|
||||
├── e2e/ # Playwright E2E tests
|
||||
│ ├── app.spec.ts # App loading tests
|
||||
│ ├── core-flows.spec.ts # Main user workflows
|
||||
│ ├── keyboard-shortcuts.spec.ts
|
||||
│ ├── quick-open.spec.ts
|
||||
│ ├── screenshot.spec.ts # Visual regression screenshots
|
||||
│ └── ...
|
||||
├── mcp-server/ # MCP bridge (Node.js)
|
||||
│ ├── index.js # MCP server entry (stdio, 14 tools)
|
||||
│ ├── vault.js # Vault file operations
|
||||
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
|
||||
│ ├── test.js # MCP server tests
|
||||
│ └── package.json
|
||||
│
|
||||
├── e2e/ # Playwright E2E tests (~26 specs)
|
||||
├── tests/smoke/ # Smoke tests (~10 specs)
|
||||
├── design/ # Per-task design files
|
||||
├── demo-vault-v2/ # Getting Started demo vault
|
||||
├── scripts/ # Build/utility scripts
|
||||
│
|
||||
├── package.json # Frontend dependencies + scripts
|
||||
├── vite.config.ts # Vite bundler config
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── playwright.config.ts # E2E test config
|
||||
├── CLAUDE.md # Project instructions for Claude
|
||||
├── ui-design.pen # Master design file
|
||||
├── CLAUDE.md # Project instructions
|
||||
└── docs/ # This documentation
|
||||
```
|
||||
|
||||
@@ -109,9 +195,10 @@ laputa-app/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/App.tsx` | The root component. Shows how the 4-panel layout is assembled and how state flows between components. |
|
||||
| `src/App.tsx` | Root component. Shows the 4-panel layout, state flow, and how all features connect. |
|
||||
| `src/types.ts` | All shared TypeScript types. Read this first to understand the data model. |
|
||||
| `src-tauri/src/lib.rs` | All 9 Tauri commands in one place. This is the frontend-backend API surface. |
|
||||
| `src-tauri/src/commands.rs` | All 61 Tauri command handlers. This is the frontend-backend API surface. |
|
||||
| `src-tauri/src/lib.rs` | Tauri setup, command registration, startup tasks, WebSocket bridge lifecycle. |
|
||||
|
||||
### Data layer
|
||||
|
||||
@@ -119,31 +206,55 @@ laputa-app/
|
||||
|------|---------------|
|
||||
| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. |
|
||||
| `src/hooks/useNoteActions.ts` | Tab management, wikilink navigation, frontmatter CRUD. The biggest hook. |
|
||||
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, Getting Started vault. |
|
||||
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
|
||||
|
||||
### Backend
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src-tauri/src/vault.rs` | Vault scanning, frontmatter parsing, entity type inference. The core backend logic. |
|
||||
| `src-tauri/src/frontmatter.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git.rs` | All git operations. Shells out to git CLI. |
|
||||
| `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. |
|
||||
| `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. |
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). |
|
||||
| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. |
|
||||
| `src-tauri/src/search.rs` | qmd search integration (keyword/semantic/hybrid). |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
|
||||
### Editor
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/Editor.tsx` | BlockNote setup, custom wikilink schema, tab bar, breadcrumb bar, diff toggle. |
|
||||
| `src/utils/wikilinks.ts` | The wikilink preprocessing pipeline (markdown → BlockNote blocks with wikilinks). |
|
||||
| `src/components/EditorTheme.css` | BlockNote CSS overrides for typography and styling. |
|
||||
| `src/components/Editor.tsx` | BlockNote setup, tab bar, breadcrumb bar, diff/raw toggle. |
|
||||
| `src/components/editorSchema.tsx` | Custom wikilink inline content type definition. |
|
||||
| `src/utils/wikilinks.ts` | Wikilink preprocessing pipeline (markdown ↔ BlockNote). |
|
||||
| `src/components/RawEditorView.tsx` | CodeMirror 6 raw markdown editor. |
|
||||
|
||||
### Styling
|
||||
### AI
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/index.css` | All CSS custom properties (colors, spacing). The design token source of truth. |
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — Claude CLI with tool execution, reasoning, actions. |
|
||||
| `src/components/AIChatPanel.tsx` | AI chat panel — API-based chat without tools. |
|
||||
| `src/hooks/useAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
|
||||
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
|
||||
|
||||
### Styling & Themes
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/index.css` | All CSS custom properties. The design token source of truth. |
|
||||
| `src/theme.json` | Editor-specific theme (fonts, headings, lists, code blocks). |
|
||||
| `src/hooks/useTheme.ts` | Converts theme.json into CSS variables for the editor. |
|
||||
| `src/hooks/useThemeManager.ts` | Vault theme lifecycle (switch, create, apply, live preview). |
|
||||
| `docs/THEMING.md` | Full theme system documentation. |
|
||||
|
||||
### Settings & Config
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault UI preferences (zoom, view mode, colors). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection. |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
@@ -169,13 +280,15 @@ No global state management (no Redux, no Context). `App.tsx` owns the state and
|
||||
|
||||
```typescript
|
||||
type SidebarSelection =
|
||||
| { kind: 'filter'; filter: 'all' | 'favorites' }
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
| { kind: 'sectionGroup'; type: string }
|
||||
| { kind: 'entity'; entry: VaultEntry }
|
||||
| { kind: 'topic'; entry: VaultEntry }
|
||||
```
|
||||
|
||||
This pattern makes it easy to handle all selection states exhaustively.
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. The native macOS menu bar also triggers commands via `useMenuEvents`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
@@ -183,26 +296,28 @@ This pattern makes it easy to handle all selection states exhaustively.
|
||||
# Unit tests (fast, no browser)
|
||||
pnpm test
|
||||
|
||||
# Rust tests
|
||||
cd src-tauri && cargo test
|
||||
# Unit tests with coverage (must pass ≥70%)
|
||||
pnpm test:coverage
|
||||
|
||||
# E2E tests (requires dev server)
|
||||
pnpm test:e2e
|
||||
# Rust tests
|
||||
cargo test
|
||||
|
||||
# Rust coverage (must pass ≥85% line coverage)
|
||||
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
|
||||
|
||||
# Playwright smoke tests (requires dev server)
|
||||
BASE_URL="http://localhost:5173" pnpm playwright:smoke
|
||||
|
||||
# Single Playwright test
|
||||
npx playwright test e2e/screenshot.spec.ts
|
||||
|
||||
# Visual verification screenshots
|
||||
npx playwright test e2e/screenshot.spec.ts
|
||||
# Screenshots saved to test-results/
|
||||
BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add a new Tauri command
|
||||
|
||||
1. Write the Rust function in `vault.rs`, `git.rs`, or a new module
|
||||
2. Add `#[tauri::command]` wrapper in `lib.rs`
|
||||
1. Write the Rust function in the appropriate module (`vault/`, `git/`, etc.)
|
||||
2. Add a command handler in `commands.rs`
|
||||
3. Register it in the `generate_handler![]` macro in `lib.rs`
|
||||
4. Call it from the frontend via `invoke()` in the appropriate hook
|
||||
5. Add a mock handler in `mock-tauri.ts`
|
||||
@@ -217,6 +332,25 @@ npx playwright test e2e/screenshot.spec.ts
|
||||
### Add a new entity type
|
||||
|
||||
1. Create the folder in the vault (e.g., `~/Laputa/mytype/`)
|
||||
2. Add the folder → type mapping in `vault.rs:parse_md_file()` (the `match` on folder names)
|
||||
3. The sidebar section groups are defined as `SECTION_GROUPS` in `Sidebar.tsx` — add it there
|
||||
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it
|
||||
2. Create a type document: `type/mytype.md` with `type: Type` frontmatter (icon, color, order, etc.)
|
||||
3. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true`
|
||||
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog
|
||||
|
||||
### Add a command palette entry
|
||||
|
||||
1. Register the command in `useAppCommands.ts` via the command registry
|
||||
2. Add a corresponding menu bar item in `menu.rs` for discoverability
|
||||
3. If it has a keyboard shortcut, register it in `useAppKeyboard.ts`
|
||||
|
||||
### Add or modify a theme
|
||||
|
||||
1. **Vault-based** (preferred): Create/edit a markdown note in `theme/` with `type: Theme` frontmatter
|
||||
2. **Programmatic**: Edit defaults in `src-tauri/src/theme/defaults.rs`
|
||||
3. See `docs/THEMING.md` for the full property reference
|
||||
|
||||
### Work with the AI agent
|
||||
|
||||
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
|
||||
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
|
||||
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
|
||||
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)
|
||||
|
||||
@@ -263,7 +263,7 @@ M1 passed all tests but showed 0 notes — vault path wrong, error silently swal
|
||||
- [x] Tauri v2 + React 19 + TypeScript + Vite 7 project setup
|
||||
- [x] Configure Vitest (7 tests), Playwright (2 E2E tests), Rust tests (10 tests)
|
||||
- [x] Rust backend: `list_vault` command — scans directory, parses YAML frontmatter via `gray_matter` crate
|
||||
- [x] Rust backend: extracts Is A, aliases, Belongs to, Related to, Status, Owner, Cadence, title from H1
|
||||
- [x] Rust backend: extracts type (Is A), aliases, Belongs to, Related to, Status, Owner, Cadence, title from H1
|
||||
- [x] React: four-panel layout (Sidebar 250px, NoteList 300px, Editor flex, Inspector 280px), all resizable
|
||||
- [x] Tauri mock layer for browser testing (`src/mock-tauri.ts`)
|
||||
- [x] Screenshot verification via Playwright (`e2e/screenshot.spec.ts`)
|
||||
@@ -281,7 +281,7 @@ bc75647 Remove unused Vite scaffold files
|
||||
### M2: Sidebar & Note List
|
||||
**Goal:** Navigate the vault via sidebar, see filtered note lists.
|
||||
- [ ] Sidebar: Filters section (All Notes, Favorites, Trash)
|
||||
- [ ] Sidebar: Section Groups (Projects, Experiments, Responsibilities, Procedures) — populated from frontmatter `Is A`
|
||||
- [ ] Sidebar: Section Groups (Projects, Experiments, Responsibilities, Procedures) — populated from frontmatter `type:`
|
||||
- [ ] Sidebar: Topics — flat list, populated from `Related to` topic links
|
||||
- [ ] Note list: show title, preview snippet, date, type indicator
|
||||
- [ ] Note list: sort by last modified (descending)
|
||||
@@ -313,7 +313,7 @@ bc75647 Remove unused Vite scaffold files
|
||||
|
||||
### M5: File Operations & Polish
|
||||
**Goal:** Create, rename, delete files. Polish for daily-driver use.
|
||||
- [ ] Create new note (with type selector → sets `Is A` and target folder)
|
||||
- [ ] Create new note (with type selector → sets `type:` and target folder)
|
||||
- [ ] Rename file (updates filename + title)
|
||||
- [ ] Delete → move to trash
|
||||
- [ ] Keyboard shortcuts (Cmd+N new, Cmd+S save, Cmd+P quick open/search)
|
||||
|
||||
108
docs/ROADMAP.md
Normal file
108
docs/ROADMAP.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Laputa — Product Roadmap
|
||||
|
||||
*Strategic directions, not implementation tasks. Each item here represents a direction that will be broken down into many smaller tasks when the time comes.*
|
||||
|
||||
*Updated: March 2026.*
|
||||
|
||||
---
|
||||
|
||||
## Consolidation sprint (current priority)
|
||||
|
||||
Before building new features, the architectural foundations must be solid. Key structural fixes underway:
|
||||
|
||||
- Move vault cache outside the vault directory (→ `~/.laputa/cache/`) with atomic writes
|
||||
- Flip `type:` to canonical field in Rust parser (`Is A:` becomes alias)
|
||||
- Remove `allContent` from the architecture — derive backlinks from open tabs only
|
||||
- ~~Remove hardcoded `RELATIONSHIP_KEYS` — detect wikilink fields dynamically~~ ✅ Done
|
||||
- Fix hardcoded vault path in `resolveNewNote` / `resolveNewType` / `resolveDailyNote`
|
||||
- Define and enforce the three-source-of-truth contract (filesystem → cache → React state)
|
||||
|
||||
These are not features — they are the foundation everything else is built on.
|
||||
|
||||
---
|
||||
|
||||
## Strategic directions
|
||||
|
||||
### 1. Semantic properties
|
||||
|
||||
**What:** Conventional frontmatter field names (`status:`, `url:`, `start_date:`, `end_date:`, `goal:`, `result:`) trigger rich UI rendering beyond the Properties panel — chips in the note list, progress indicators in the editor header, date range badges.
|
||||
|
||||
**Why:** Notes are not just documents. A Project has a start and end. A Responsibility has KPIs. A Procedure has an owner and a cadence. The app should surface this structure visually, not just store it as plain text.
|
||||
|
||||
**Convention over configuration:** the rendering rules ship as sensible defaults. Users can override via `config/semantic-properties.md` in the vault — a plain markdown file, editable from within the app.
|
||||
|
||||
**Draft tasks:** created in Todoist. To be prioritized after consolidation sprint.
|
||||
|
||||
---
|
||||
|
||||
### 2. Default relationships in Properties panel
|
||||
|
||||
**What:** The Properties panel shows a set of relationship fields by default — even when empty — guiding the user toward a connected knowledge graph. Defaults include: Belongs to, Related to, Events, People (Type is already shown).
|
||||
|
||||
**Why:** A new note starts with a completely empty Properties panel today. There's no guidance on how to connect it. Laputa is opinionated — it should show you the connections that matter.
|
||||
|
||||
**Convention over configuration:** the default list is built in, but can be overridden via `config/relations.md` in the vault.
|
||||
|
||||
**Draft tasks:** created in Todoist. Needs design discussion (per-type overrides?) before implementation.
|
||||
|
||||
---
|
||||
|
||||
### 3. Global workspace filter
|
||||
|
||||
**What:** A top-level workspace switcher (below the traffic lights) that filters the entire app — sidebar, note list, search — to show only notes belonging to the selected workspace, plus shared notes (those without a Workspace field).
|
||||
|
||||
**Why:** A single vault often contains both personal and work content. A workspace filter lets you focus on one context at a time without cognitive overhead.
|
||||
|
||||
**How:** Notes opt into a workspace via `Workspace: [[workspace/refactoring]]` frontmatter. Workspace notes are auto-detected from the `workspace/` folder. No setup required.
|
||||
|
||||
**Future trajectory:** Workspaces are the seed of a multi-vault, multi-user access control model. In the future, workspaces may map to separate Git repositories — each with their own access permissions. Different people see different workspaces (vaults). Git provides the audit trail. This enables Laputa to grow from a personal tool to a small-team knowledge base without rebuilding the product.
|
||||
|
||||
**Draft tasks:** created in Todoist. Lower priority than semantic properties and default relationships.
|
||||
|
||||
---
|
||||
|
||||
### 4. Inbox and capture pipeline
|
||||
|
||||
**What:** An Inbox section that surfaces all unorganized notes — those with no outgoing relationships. Replaces "All Notes" as the primary landing section. Capture integrations (Chrome extension, iPhone share sheet, Readwise sync) feed into the inbox automatically.
|
||||
|
||||
**Why:** Capture and organize are fundamentally different activities and should be treated separately. Today Laputa has no concept of an unorganized note — everything lands in the same pool. The inbox makes the unorganized state visible and actionable, creating a discipline: Inbox Zero, reached weekly.
|
||||
|
||||
**The inbox as a smart filter:** not a folder. Any note without `Belongs to:`, `Related to:`, or other meaningful relationship is automatically in the inbox. Connecting a note to something removes it from the inbox, automatically.
|
||||
|
||||
**Capture integrations (future, each a separate feature):**
|
||||
- Chrome extension → saves URL/clip as a note to the vault via Git
|
||||
- iPhone share sheet → quick capture from any app
|
||||
- Readwise / Kindle highlights → synced via Git automation
|
||||
- Voice memo → transcribed and dropped into inbox
|
||||
|
||||
**Priority:** The Inbox UI is high-value and can be implemented without the capture integrations. Integrations come after.
|
||||
|
||||
---
|
||||
|
||||
### 5. Mobile apps
|
||||
|
||||
**What:** Native apps for iPhone and iPad — not ports of the desktop app, but purpose-built for each form factor.
|
||||
|
||||
**iPhone:** Optimized for fast capture. Quick note creation, voice memos, brief thoughts. The primary use case is getting something into the vault quickly while away from the desk. Minimal reading and editing.
|
||||
|
||||
**iPad:** A more capable mirror of the desktop experience — reading, editing, navigating the vault. Not a full four-panel layout, but enough to work on notes meaningfully. Think "laptop replacement for light work sessions."
|
||||
|
||||
**Why it matters:** Laputa's value as a personal knowledge system depends on being able to capture things wherever you are. Without mobile capture, important notes get lost or end up scattered in other apps.
|
||||
|
||||
**Sync:** Git-based, same as desktop. The vault is a Git repo — mobile apps commit and pull like any other client.
|
||||
|
||||
**Priority:** After the desktop experience is solid. Not before.
|
||||
|
||||
---
|
||||
|
||||
## Principles for this roadmap
|
||||
|
||||
- **Foundations before features** — a shaky architecture multiplies the cost of every feature built on top of it
|
||||
- **Convention over configuration** — ship strong defaults, allow customization via vault files
|
||||
- **File-first** — every strategic direction must be achievable without breaking the markdown-files-on-disk model
|
||||
- **AI-readable by design** — conventions that humans find intuitive should also be legible to AI agents navigating the vault
|
||||
|
||||
---
|
||||
|
||||
*For active tasks and bugs, see the Todoist board (Laputa App project).*
|
||||
*For architectural decisions and design principles, see [ARCHITECTURE.md](./ARCHITECTURE.md) and [VISION.md](./VISION.md).*
|
||||
280
docs/VISION.md
280
docs/VISION.md
@@ -1,156 +1,194 @@
|
||||
# Laputa — Product Vision
|
||||
|
||||
*Written by Brian based on conversations with Luca Rossi, Feb 2026.*
|
||||
*Written by Brian based on conversations with Luca Rossi, Feb–Mar 2026.*
|
||||
*This is a living document — update it as the vision evolves.*
|
||||
|
||||
---
|
||||
|
||||
## Why Laputa exists
|
||||
## Why this, why now, why us
|
||||
|
||||
Luca has been doing personal knowledge management since university — long before it had a name. Over the years, through two startups, becoming a CTO, and eventually building Refactoring (a newsletter publishing 2-3 articles/week), note-taking evolved from a nice-to-have to a core part of his work. The ability to synthesize ideas, connect concepts across time, and turn accumulated knowledge into articles reliably every week — this only works with a well-structured system.
|
||||
Before the what and how: the why.
|
||||
|
||||
For years, that system lived in Notion. But over time, the overlap between what Notion offers and what Luca actually needs started to shrink. Notion became simultaneously too narrow (missing specific things he needed) and too wide (full of flexibility he didn't want). The gap became impossible to ignore when AI entered the picture.
|
||||
The best projects are built by people who have an unusually strong answer to "why are you the right person to build this?" This is that answer.
|
||||
|
||||
## The core insight: local files + Git = AI-native PKM
|
||||
**Luca Rossi** is a startup founder and former generalist CTO — someone who can build a product end-to-end across code, design, scope, and product. And for the last five years, full-time, he has run Refactoring: a technical newsletter with nearly 200,000 subscribers, for which he has written over 300 original articles. In word count, that's roughly two *Lord of the Rings* novels.
|
||||
|
||||
The fundamental insight behind Laputa is architectural: **a knowledge base made of local Markdown files, version-controlled with Git, is orders of magnitude more AI-friendly than any SaaS-based system.**
|
||||
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Laputa is an attempt to bottle that system.
|
||||
|
||||
Notion's AI struggles with complex workspaces — slow, inaccurate, often failing to understand its own structure. Meanwhile, an AI like Claude or Claude Code working on a local vault of Markdown files can read, edit, and reorganize thousands of documents in seconds, with full comprehension.
|
||||
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Laputa is not theorized — it's been battle-tested for years at scale.
|
||||
|
||||
This isn't a feature — it's a structural advantage that no Notion redesign can fix. The architecture *is* the product.
|
||||
**The distribution is built in.** Refactoring reaches ~200,000 engineers, managers, and technical leaders — exactly the people most receptive to a tool like this. The audience already trusts the author on this topic, because they've been reading his writing about knowledge management and learning for years.
|
||||
|
||||
Additional benefits that fall out of this choice for free:
|
||||
- **Version control**: every change is tracked, diffable, reversible
|
||||
- **Open format**: your knowledge is yours, readable by any tool, forever
|
||||
- **Remote AI access**: an AI agent can commit to your Git repo from anywhere — your knowledge base becomes programmable
|
||||
- **Zero lock-in**: if something better comes along, you leave. The trust between Laputa and the user is earned daily, not enforced by proprietary formats
|
||||
This is not a product looking for a market. It's a tool built by its first power user, for an audience that already knows and trusts him.
|
||||
|
||||
## Why not just use Obsidian?
|
||||
**Why Laputa, in the context of Refactoring.**
|
||||
|
||||
Refactoring is a newsletter about how software is built, how teams work, and how digital products are developed — written from Luca's experience and conversations with other tech leaders. A natural question follows: what is the author's own current experience building software with AI?
|
||||
|
||||
Laputa answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
|
||||
|
||||
This is why Laputa is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
|
||||
|
||||
The strategy is coherent: build the tool you describe, make the work visible, let the product speak for the author.
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
Most people who want to work effectively with AI face a version of the same problem: **they don't have their knowledge organized in a way that AI can actually use.**
|
||||
|
||||
They have notes scattered across Notion, Apple Notes, browser bookmarks, and email. Some of it is structured, most of it isn't. Even the people who do maintain a knowledge base discover that AI tools — ChatGPT, Notion AI, others — struggle to navigate it meaningfully. The knowledge is there, but it's not *accessible*.
|
||||
|
||||
The problem has two distinct layers:
|
||||
|
||||
1. **Architectural**: most knowledge tools store data in proprietary formats on remote servers. AI tools can't read them efficiently, can't commit changes back, can't reason over the full structure. The format itself creates a ceiling.
|
||||
|
||||
2. **Methodological**: even with the right tool, most people don't know *how* to organize knowledge so it becomes useful over time — what to capture, how to connect things, how to turn raw notes into a system that works with you instead of against you.
|
||||
|
||||
Laputa addresses both layers, together. That's what makes it different.
|
||||
|
||||
---
|
||||
|
||||
## The insight: tool and method, together
|
||||
|
||||
Most PKM tools give you a blank canvas and leave the rest to you. They solve the first problem (somewhere to put things) but not the second (how to organize them). The result is that sophisticated users build complex custom systems, while everyone else gives up.
|
||||
|
||||
Laputa's position is different: **we ship the method alongside the tool.**
|
||||
|
||||
The method is opinionated but not rigid. It tells you: here's how to think about your work, here's where different kinds of notes belong, here's how to connect them. If it fits your needs — great, start immediately. If your situation is different — customize it. The types, the relationships, the structure can all be changed. But you don't have to figure it out from scratch.
|
||||
|
||||
This combination — an opinionated method on top of a technically excellent foundation — is what makes Laputa genuinely useful to people who are stuck, not just people who already know what they're doing.
|
||||
|
||||
---
|
||||
|
||||
## The method: a framework for knowledge work
|
||||
|
||||
### The knowledge ontology
|
||||
|
||||
Laputa organizes work around two axes:
|
||||
|
||||
| | **One-time** | **Recurring** |
|
||||
|---|---|---|
|
||||
| **Multi-session** | **Project** (has a start and end) | **Responsibility** (no end, measured by KPIs) |
|
||||
| **Single-session** | *Task* (lives in a task manager) | **Procedure** (checklist, routine) |
|
||||
|
||||
Everything else is context:
|
||||
- **Notes** — the atomic unit. Any note connects to one or more of the above.
|
||||
- **Topics** — areas of interest with no performance expectation. A knowledge repository.
|
||||
- **Events** — things that happened, anchored to a date.
|
||||
- **People** — contacts and their history.
|
||||
|
||||
Relations between notes are first-class citizens — not just wiki-links, but typed, bidirectional connections that make the knowledge graph navigable.
|
||||
|
||||
This ontology is not arbitrary. It maps cleanly to how both individuals and organizations actually structure their work: companies have projects, responsibilities, procedures, and people. So do independent creators. So do individuals managing their lives.
|
||||
|
||||
### Knowledge has a purpose
|
||||
|
||||
A principle that underlies everything in Laputa: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
|
||||
|
||||
This is the difference between a knowledge system that works over years and one that collapses after a few weeks. Without a real purpose, the maintenance cost of taking notes is never justified, and people stop. With a purpose — writing regularly, building things, making decisions — the system pays for itself.
|
||||
|
||||
What you *do* with organized knowledge depends on who you are:
|
||||
|
||||
- **Writers and content creators** — the output is articles, essays, posts. Captures become highlights, highlights become **evergreen notes** (small, atomic, timeless ideas), evergreen notes become building blocks for articles. Evergreen notes are a middle layer: not the raw input, not the final output, but the refined reusable units that make writing easier and faster.
|
||||
- **Builders and project-driven people** — the output is shipped work. Captures feed projects, decisions, and procedures. Evergreen notes matter less; the project knowledge graph matters more.
|
||||
- **Operators and managers** — the output is better systems and decisions. Captures feed responsibilities (KPIs, workflows) and procedures (how we do things). The value accumulates in the recurring structure, not in individual notes.
|
||||
|
||||
The framework is flexible enough to fit all three — and most people are a mix. What stays constant is the flow: **capture → organize → express**. The *what* of expressing changes; the discipline doesn't.
|
||||
|
||||
### The two-phase workflow: capture and organize
|
||||
|
||||
Notes move through two distinct phases, and the transition between them is intentional.
|
||||
|
||||
**Capture** — fast, frictionless, available everywhere. A thought, a saved article, a Kindle highlight, a voice memo. The cardinal rule: never let friction during capture cause a good idea to be lost. Captured notes land in the vault unconnected — no relationships, no organization. That's fine. That's the point.
|
||||
|
||||
**Organize** — a deliberate, periodic activity (weekly is the natural cadence). You ask: *what is this useful for?* Many things that seemed important when captured won't survive this question — deleting >50% of captures is normal and healthy. For the things that survive: connect them. Link to a Project, a Responsibility, a Topic. Every note should eventually connect to at least one actionable container. If you can't connect something to anything, that's a signal worth paying attention to.
|
||||
|
||||
**The Inbox** is the UI expression of this split: a smart section that shows all unorganized notes — those with no outgoing relationships. The goal is Inbox Zero, reached periodically (weekly). The inbox is not a folder; it's a derived state. Connecting a note to something removes it automatically.
|
||||
|
||||
### Convention over configuration
|
||||
|
||||
The method lives in the app as *conventions*: standard field names and folder structures that have well-defined meanings and trigger specific behavior.
|
||||
|
||||
`status:` shows a colored chip. `Workspace: [[workspace/refactoring]]` assigns a note to a context. `Belongs to:` connects it to its parent. `start_date:` and `end_date:` show a duration badge. The app recognizes these by convention, without any setup.
|
||||
|
||||
Users who want more can override the defaults: `config/relations.md` changes which relationship fields appear by default; `config/semantic-properties.md` controls how fields are rendered. But the defaults work immediately, for everyone.
|
||||
|
||||
This is convention *over* configuration — not convention *instead of* it.
|
||||
|
||||
---
|
||||
|
||||
## The foundation: architecture that earns trust
|
||||
|
||||
The method is only as good as the system it runs on. Laputa's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
|
||||
|
||||
### Local files, version-controlled with Git
|
||||
|
||||
Every note is a plain Markdown file on your disk. There is no database, no proprietary format, no sync lock-in. The files are readable by any tool that can open a text file — today and in twenty years.
|
||||
|
||||
Git provides version control: every change is tracked, diffable, reversible. You have a full audit trail of what changed, when, and why. Collaboration happens via Git — the same way software teams have collaborated for decades, without any proprietary cloud in between.
|
||||
|
||||
### AI-native by design
|
||||
|
||||
A vault of plain Markdown files, version-controlled with Git, is dramatically more AI-friendly than any SaaS-based system.
|
||||
|
||||
An AI agent working on a local vault can read thousands of notes in seconds, understand their structure, write new ones, connect existing ones, and commit the changes back — all with full comprehension. Notion's AI can't do this. No SaaS-based AI can do this, because the architecture doesn't allow it.
|
||||
|
||||
More importantly: the more a vault follows Laputa's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
|
||||
|
||||
### Open and exit-friendly
|
||||
|
||||
The trust between Laputa and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
|
||||
|
||||
---
|
||||
|
||||
## Why not Obsidian?
|
||||
|
||||
Obsidian is the obvious comparison. The difference is philosophy:
|
||||
|
||||
- **Obsidian** is a blank canvas. Infinitely configurable via plugins, themes, and community extensions. Great for power users who want to build their own system from scratch.
|
||||
- **Laputa** is opinionated. It ships with a point of view on how to organize knowledge, with sensible defaults that work out of the box — no plugin hunting required.
|
||||
- **Obsidian** is a blank canvas. Infinitely configurable via plugins and community extensions. Powerful for users who want to build their own system — and who have the time and patience to do so.
|
||||
- **Laputa** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
|
||||
|
||||
Obsidian also treats Git as an afterthought (its business model is built around proprietary sync). In Laputa, **Git is a first-class citizen** — the obvious, natural way to sync and collaborate.
|
||||
Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Laputa, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
|
||||
|
||||
## The knowledge ontology
|
||||
---
|
||||
|
||||
Laputa is built around a clear conceptual model, inspired by PARA but adapted to Luca's real-world usage:
|
||||
## Who it's for, and where it's going
|
||||
|
||||
**Two axes:**
|
||||
1. *One-time* vs *recurring*
|
||||
2. *Single-session* vs *multi-session*
|
||||
### Three stages of adoption
|
||||
|
||||
**Four action types:**
|
||||
- **Projects** — one-time, multi-session (have a start and end)
|
||||
- **Responsibilities** — recurring, multi-session (no end, measured by KPIs)
|
||||
- **Tasks** — one-time, single-session (live in Todoist)
|
||||
- **Procedures** — recurring, single-session (checklists, routines)
|
||||
Laputa is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
|
||||
|
||||
**Knowledge containers:**
|
||||
- **Notes** — the atomic unit. Can belong to any of the above.
|
||||
- **Topics** — areas of interest with no performance expectation (like labels/tags). E.g. "front-end engineering", "interior design"
|
||||
- **Events** — things that happened, tied to a date
|
||||
- **People** — contacts, with a log of interactions
|
||||
**Stage 1: Personal PKM + AI context** *(current)*
|
||||
A single person manages their knowledge, life, and work in a local vault. The primary collaborator is AI. The vault gives structure to one person's context, making it legible to an AI that can assist meaningfully across all areas of work and life. The method helps structure the knowledge; the AI helps use it.
|
||||
|
||||
**Relations** between notes are first-class citizens — not just wiki-links, but typed, bidirectional connections.
|
||||
**Stage 2: Independent knowledge workers**
|
||||
Content creators, freelancers, consultants. People with maximum incentive *and* maximum agency to build a real system. They have projects, clients, responsibilities — and they work alone or in very small teams. The same ontology applies: a newsletter creator has editorial projects, a subscriber-growth responsibility, and a publishing procedure. AI collaboration deepens: the AI can see not just personal notes but client commitments, content pipelines, recurring workflows.
|
||||
|
||||
## The deeper mission: AI context scaffolding
|
||||
**Stage 3: Small teams**
|
||||
The ontology scales to organizations. Companies have projects, responsibilities, procedures, and people — the same categories, at a larger scale. The access model changes: different people see different subsets of the vault, via workspace filtering and Git-based access control. Version history gives teams a full audit trail. AI agents become shared collaborators on team knowledge, not just personal assistants.
|
||||
|
||||
Most people today can't effectively share context about their lives with AI. They don't know what to write, how to structure it, or when. The result is that AI assistants — even the best ones — are working with a fraction of the context they need.
|
||||
**What makes this trajectory coherent:** the foundational model — local files, Git-versioned, structured by conventions — doesn't need to be rebuilt at each stage. It extends naturally.
|
||||
|
||||
Laputa's goal is not just to be an efficient place to store things. It's to provide **scaffolding that makes it easy for people to externalize their knowledge in a structured, AI-readable way** — without having to figure out the system themselves.
|
||||
### The right early adopters
|
||||
|
||||
The vision: a person who uses Laputa well has built a second brain that any AI can read, reason over, and contribute to. Not the naive "memory" that ChatGPT builds from chat history — but an intentional, curated, structured representation of their work and life.
|
||||
|
||||
## Target user (v1)
|
||||
|
||||
Developers and technically-minded knowledge workers who:
|
||||
- Are frustrated with Notion's complexity or performance
|
||||
The first users who will get the most from Laputa are technically-minded individuals who:
|
||||
- Are frustrated with Notion's performance, complexity, or lock-in
|
||||
- Understand or are comfortable with Git
|
||||
- Want a system that's AI-native by design, not by bolted-on features
|
||||
- Value owning their data and formats
|
||||
- Value owning their data
|
||||
|
||||
Broader audiences (non-developers) are a future consideration — they'll need more onboarding and scaffolding to get started, but the underlying model is designed to work for anyone.
|
||||
Broader audiences will follow as the onboarding experience matures and the conventions become easier to adopt.
|
||||
|
||||
## Current state
|
||||
|
||||
A living snapshot of what's built vs what's missing. Updated as features ship.
|
||||
|
||||
### ✅ What's working today
|
||||
|
||||
**Core editor & notes**
|
||||
- BlockNote-based editor (block-style, Notion-like) with Markdown files on disk
|
||||
- Cmd+S save with dirty state indicator (orange dot = modified, green dot = new)
|
||||
- Word count (frontmatter excluded)
|
||||
- Rename note by double-clicking tab
|
||||
- Drag & drop images into editor
|
||||
- Wiki-links with `[[` autocomplete (2+ chars, max 20 results, colored by note type)
|
||||
|
||||
**Navigation & layout**
|
||||
- 4-panel layout: sidebar / note list / editor / inspector
|
||||
- Collapsible sidebar and note list (Cmd+1/2/3)
|
||||
- Tabs with drag-to-reorder
|
||||
- Quick open (Cmd+P) by title
|
||||
- Virtual list rendering for NoteList (handles 9000+ notes without lag)
|
||||
|
||||
**Properties & types**
|
||||
- Inspector panel with editable vs read-only properties
|
||||
- Change note type from Inspector (picker/dropdown)
|
||||
- Property value text consistent at 12px
|
||||
- URL properties: click to open in browser, underline on hover
|
||||
- Bidirectional relationships (Referenced By panel)
|
||||
- Editable relations: add/remove linked notes
|
||||
- `type:` as canonical key (removed `is a:` property)
|
||||
|
||||
**Sections & customization**
|
||||
- Sidebar sections with custom icons (290 Phosphor icons, searchable) and colors
|
||||
- Changes view: click "N pending" in status bar → filtered list of modified notes
|
||||
|
||||
**Git integration**
|
||||
- Commit & push from within the app (saves pending changes first)
|
||||
- Modified files indicator in status bar, NoteList, and TabBar
|
||||
- Git history per note (version history)
|
||||
- Dirty state clears correctly after save/rename
|
||||
|
||||
**Vault management**
|
||||
- Dynamic vault picker (no hardcoded paths)
|
||||
- Create new local vault or clone/create from GitHub repo
|
||||
- GitHub OAuth login (device flow)
|
||||
|
||||
**Settings & infrastructure**
|
||||
- Settings panel (Cmd+,): AI provider API keys, stored in app_config_dir
|
||||
- In-app auto-updater (Tauri updater + GitHub Releases)
|
||||
- CI: lint, TypeScript, tests (84% frontend coverage, 85%+ Rust), CodeScene ≥9.2
|
||||
- Universal macOS binary, auto-released on every merge to main
|
||||
|
||||
### 🚧 What's missing (Open tasks)
|
||||
|
||||
**Bugs**
|
||||
- Word count still including some frontmatter in edge cases (under investigation)
|
||||
|
||||
**Improvements**
|
||||
- Date picker for date-type properties
|
||||
- Vista Changes: differentiate new vs modified more clearly
|
||||
- Relation editing UX polish
|
||||
|
||||
**Features (prioritized)**
|
||||
- Full-text search with semantic support (qmd integration)
|
||||
- Command palette (Cmd+K) — Raycast-style actions
|
||||
- `mock-tauri.ts` and `App.tsx` refactor (code health)
|
||||
|
||||
**Vision-level features (not started)**
|
||||
- Onboarding / getting started flow with default note types
|
||||
- AI-powered features (search, summarization, linking suggestions)
|
||||
- Graph view
|
||||
- Mobile / web access via Git remote
|
||||
---
|
||||
|
||||
## Design principles
|
||||
|
||||
1. **Opinionated but not rigid** — ship strong defaults, allow customization where it matters
|
||||
2. **Git-first** — sync, history, and collaboration via Git; no proprietary cloud
|
||||
3. **AI-native architecture** — local files, open formats, readable by any AI tool
|
||||
4. **Zero lock-in** — earn trust daily; the exit door is always open
|
||||
5. **Ready out of the box** — no plugin hunting, no theme configuration; it just works
|
||||
6. **Relations as first-class citizens** — connections between notes are as important as the notes themselves
|
||||
1. **Opinionated but not rigid** — ship the method and the defaults; allow customization where it matters
|
||||
2. **Convention over configuration** — standard field names trigger rich behavior automatically; users can override via vault config files
|
||||
3. **Git-first** — sync, history, collaboration, and audit trail via Git; no proprietary cloud
|
||||
4. **AI-native architecture** — local files, open formats, structured by conventions legible to both humans and AI
|
||||
5. **Zero lock-in** — earn trust daily; the exit door is always open
|
||||
6. **Capture and organize are separate** — the inbox makes unorganized notes visible; Inbox Zero is the discipline
|
||||
7. **Relations as first-class citizens** — connections between notes are as important as the notes themselves
|
||||
8. **Filesystem as the single source of truth** — the app never owns the data; cache and UI state are always derived and reconstructible
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* - 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
|
||||
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
|
||||
* - refresh_vault: trigger vault rescan so new/modified files appear
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
@@ -94,6 +96,28 @@ const TOOLS = [
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'highlight_editor',
|
||||
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' },
|
||||
path: { type: 'string', description: 'Optional note path to associate with the highlight' },
|
||||
},
|
||||
required: ['element'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refresh_vault',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Optional specific note path that changed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
@@ -101,6 +125,8 @@ const TOOL_HANDLERS = {
|
||||
get_vault_context: handleVaultContext,
|
||||
get_note: handleGetNote,
|
||||
open_note: handleOpenNote,
|
||||
highlight_editor: handleHighlightEditor,
|
||||
refresh_vault: handleRefreshVault,
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
@@ -122,14 +148,27 @@ async function handleGetNote(args) {
|
||||
}
|
||||
|
||||
function handleOpenNote(args) {
|
||||
// Refresh vault first so the new/modified note appears in the note list,
|
||||
// then signal the UI to open it in a tab.
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
|
||||
}
|
||||
|
||||
function handleHighlightEditor(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
|
||||
}
|
||||
|
||||
function handleRefreshVault(args) {
|
||||
broadcastUiAction('vault_changed', { path: args?.path })
|
||||
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'laputa-mcp-server', version: '0.2.0' },
|
||||
{ name: 'laputa-mcp-server', version: '0.3.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote, findMarkdownFiles,
|
||||
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
|
||||
let tmpDir
|
||||
@@ -65,39 +64,23 @@ describe('findMarkdownFiles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNote', () => {
|
||||
it('should read a note by relative path', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('Test Project'))
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
describe('getNote', () => {
|
||||
it('should read a note with parsed frontmatter', async () => {
|
||||
const note = await getNote(tmpDir, 'project/test-project.md')
|
||||
assert.equal(note.path, 'project/test-project.md')
|
||||
assert.equal(note.frontmatter.title, 'Test Project')
|
||||
assert.equal(note.frontmatter.is_a, 'Project')
|
||||
assert.ok(note.content.includes('test project for the MCP server'))
|
||||
})
|
||||
|
||||
it('should throw for missing notes', async () => {
|
||||
await assert.rejects(
|
||||
() => readNote(tmpDir, 'nonexistent.md'),
|
||||
() => getNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should create a note with frontmatter', async () => {
|
||||
const absPath = await createNote(tmpDir, 'note/new-note.md', 'My New Note', { is_a: 'Note' })
|
||||
assert.ok(absPath.endsWith('new-note.md'))
|
||||
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('title: My New Note'))
|
||||
assert.ok(content.includes('is_a: Note'))
|
||||
assert.ok(content.includes('# My New Note'))
|
||||
})
|
||||
|
||||
it('should create parent directories', async () => {
|
||||
const absPath = await createNote(tmpDir, 'deep/nested/dir/note.md', 'Deep Note')
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('# Deep Note'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should find notes matching title', async () => {
|
||||
const results = await searchNotes(tmpDir, 'Test Project')
|
||||
@@ -121,123 +104,6 @@ describe('searchNotes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendToNote', () => {
|
||||
it('should append text to a note', async () => {
|
||||
await appendToNote(tmpDir, 'note/daily-log.md', '## Evening Update\nFinished testing.')
|
||||
const content = await readNote(tmpDir, 'note/daily-log.md')
|
||||
assert.ok(content.includes('## Evening Update'))
|
||||
assert.ok(content.includes('Finished testing.'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('editNoteFrontmatter', () => {
|
||||
it('should merge a patch into frontmatter', async () => {
|
||||
const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' })
|
||||
assert.equal(updated.status, 'Completed')
|
||||
assert.equal(updated.priority, 'High')
|
||||
assert.equal(updated.title, 'Test Project')
|
||||
})
|
||||
|
||||
it('should preserve existing frontmatter fields', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
assert.ok(content.includes('status: Completed'))
|
||||
})
|
||||
|
||||
it('should throw for missing file', async () => {
|
||||
await assert.rejects(
|
||||
() => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('should delete an existing note', async () => {
|
||||
const delPath = 'note/to-delete.md'
|
||||
await createNote(tmpDir, delPath, 'To Delete')
|
||||
const absPath = path.join(tmpDir, delPath)
|
||||
|
||||
// Verify it exists
|
||||
await fs.access(absPath)
|
||||
|
||||
await deleteNote(tmpDir, delPath)
|
||||
|
||||
await assert.rejects(
|
||||
() => fs.access(absPath),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw for missing file', async () => {
|
||||
await assert.rejects(
|
||||
() => deleteNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkNotes', () => {
|
||||
it('should add a target to an array property', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' })
|
||||
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
assert.deepEqual(result, ['[[note/daily-log]]'])
|
||||
})
|
||||
|
||||
it('should not duplicate existing links', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
|
||||
await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
assert.equal(result.length, 1)
|
||||
})
|
||||
|
||||
it('should add multiple distinct links', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
|
||||
await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
|
||||
// Should have daily-log and test-project
|
||||
assert.ok(result.includes('[[note/daily-log]]'))
|
||||
assert.ok(result.includes('[[project/test-project]]'))
|
||||
assert.equal(result.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listNotes', () => {
|
||||
it('should list all notes sorted by title', async () => {
|
||||
const notes = await listNotes(tmpDir)
|
||||
assert.ok(notes.length >= 3)
|
||||
// Verify sorted by title
|
||||
for (let i = 1; i < notes.length; i++) {
|
||||
assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('should filter by type', async () => {
|
||||
const projects = await listNotes(tmpDir, 'Project')
|
||||
assert.ok(projects.length >= 1)
|
||||
for (const n of projects) {
|
||||
assert.equal(n.type, 'Project')
|
||||
}
|
||||
})
|
||||
|
||||
it('should return empty for unknown type', async () => {
|
||||
const notes = await listNotes(tmpDir, 'UnknownType12345')
|
||||
assert.equal(notes.length, 0)
|
||||
})
|
||||
|
||||
it('should support mtime sorting', async () => {
|
||||
const notes = await listNotes(tmpDir, undefined, 'mtime')
|
||||
assert.ok(notes.length >= 1)
|
||||
// Just verify it returns results without crashing
|
||||
assert.ok(notes[0].path)
|
||||
assert.ok(notes[0].title)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vaultContext', () => {
|
||||
it('should return types, recent notes, and vault path', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
@@ -264,4 +130,15 @@ describe('vaultContext', () => {
|
||||
assert.ok(note.title)
|
||||
}
|
||||
})
|
||||
|
||||
it('should include folders', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(ctx.folders.includes('project/'))
|
||||
assert.ok(ctx.folders.includes('note/'))
|
||||
})
|
||||
|
||||
it('should report correct note count', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.equal(ctx.noteCount, 3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,11 +107,22 @@ export async function vaultContext(vaultPath) {
|
||||
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
|
||||
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
|
||||
|
||||
// Read config files for AI agent context
|
||||
const configFiles = {}
|
||||
try {
|
||||
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
|
||||
const agentsContent = await fs.readFile(agentsPath, 'utf-8')
|
||||
configFiles.agents = agentsContent
|
||||
} catch {
|
||||
// config/agents.md may not exist yet
|
||||
}
|
||||
|
||||
return {
|
||||
types: [...typesSet].sort(),
|
||||
noteCount: files.length,
|
||||
folders: [...foldersSet].sort(),
|
||||
recentNotes,
|
||||
configFiles,
|
||||
vaultPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,12 @@ const TOOL_HANDLERS = {
|
||||
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),
|
||||
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_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); 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', { filterType: args.type }); return { ok: true } },
|
||||
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "husky"
|
||||
},
|
||||
@@ -73,6 +74,7 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"esbuild": "^0.27.3",
|
||||
@@ -86,6 +88,7 @@
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.0.18",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineConfig } from '@playwright/test'
|
||||
export default defineConfig({
|
||||
testDir: './tests/smoke',
|
||||
timeout: 15_000,
|
||||
retries: 0,
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:5201',
|
||||
|
||||
18
playwright.integration.config.ts
Normal file
18
playwright.integration.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/integration',
|
||||
timeout: 30_000,
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:5365',
|
||||
headless: true,
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5365'}`,
|
||||
url: process.env.BASE_URL || 'http://localhost:5365',
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
})
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -168,6 +168,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
@@ -210,6 +213,9 @@ importers:
|
||||
vitest:
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
|
||||
mcp-server:
|
||||
dependencies:
|
||||
@@ -2043,6 +2049,9 @@ packages:
|
||||
'@types/use-sync-external-store@1.5.0':
|
||||
resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.55.0':
|
||||
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -6011,6 +6020,10 @@ snapshots:
|
||||
|
||||
'@types/use-sync-external-store@1.5.0': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.10.13
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
@@ -5,13 +5,15 @@ use crate::claude_cli::{
|
||||
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
|
||||
};
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
|
||||
use crate::git::{
|
||||
GitCommit, GitPullResult, GitPushResult, 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::{MoveResult, RenameResult, VaultEntry};
|
||||
use crate::vault_config::VaultConfig;
|
||||
use crate::vault_list::VaultList;
|
||||
use crate::{
|
||||
@@ -82,10 +84,22 @@ pub fn rename_note(
|
||||
vault_path: String,
|
||||
old_path: String,
|
||||
new_title: String,
|
||||
old_title: Option<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)
|
||||
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn move_note_to_type_folder(
|
||||
vault_path: String,
|
||||
note_path: String,
|
||||
new_type: String,
|
||||
) -> Result<MoveResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let note_path = expand_tilde(¬e_path);
|
||||
vault::move_note_to_type_folder(&vault_path, ¬e_path, &new_type)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -100,6 +114,18 @@ pub fn delete_note(path: String) -> Result<String, String> {
|
||||
vault::delete_note(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_delete_notes(paths: Vec<String>) -> Result<Vec<String>, String> {
|
||||
let expanded: Vec<String> = paths.iter().map(|p| expand_tilde(p).into_owned()).collect();
|
||||
vault::batch_delete_notes(&expanded)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn empty_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::empty_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -126,6 +152,19 @@ 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 reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::invalidate_cache(std::path::Path::new(path.as_ref()));
|
||||
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reload_vault_entry(path: String) -> Result<VaultEntry, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::reload_entry(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -276,7 +315,7 @@ pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(vault_path: String) -> Result<String, String> {
|
||||
pub fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
}
|
||||
@@ -509,6 +548,16 @@ pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
|
||||
theme::restore_default_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
// Repair themes
|
||||
theme::restore_default_themes(&vault_path)?;
|
||||
// Repair config files (config/agents.md, type/config.md, AGENTS.md stub)
|
||||
vault::repair_config_files(&vault_path)?;
|
||||
Ok("Vault repaired".to_string())
|
||||
}
|
||||
|
||||
// ── Settings & config commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -648,6 +697,83 @@ mod tests {
|
||||
assert!(content.contains("Trashed at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_vault_entry_reads_from_disk() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let note = dir.path().join("note.md");
|
||||
std::fs::write(¬e, "---\nStatus: Active\n---\n# Test\n").unwrap();
|
||||
|
||||
let entry = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
|
||||
assert_eq!(entry.title, "Test");
|
||||
assert_eq!(entry.status, Some("Active".to_string()));
|
||||
|
||||
// Modify file on disk
|
||||
std::fs::write(¬e, "---\nStatus: Done\n---\n# Test\n").unwrap();
|
||||
let fresh = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
|
||||
assert_eq!(fresh.status, Some("Done".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_vault_entry_nonexistent() {
|
||||
let result = reload_vault_entry("/nonexistent/path.md".to_string());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_vault_invalidates_cache_and_rescans() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
// Init git repo for caching to work
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "t@t.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "T"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Set test cache dir to avoid polluting real cache
|
||||
let cache_dir = tempfile::TempDir::new().unwrap();
|
||||
std::env::set_var(
|
||||
"LAPUTA_CACHE_DIR",
|
||||
cache_dir.path().to_string_lossy().as_ref(),
|
||||
);
|
||||
|
||||
std::fs::write(vault.join("note.md"), "---\nTrashed: false\n---\n# Note\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Prime cache via list_vault
|
||||
let entries = list_vault(vault.to_str().unwrap().to_string()).unwrap();
|
||||
assert!(!entries[0].trashed);
|
||||
|
||||
// Trash the note on disk
|
||||
std::fs::write(vault.join("note.md"), "---\nTrashed: true\n---\n# Note\n").unwrap();
|
||||
|
||||
// reload_vault must return the updated trashed state
|
||||
let fresh = reload_vault(vault.to_str().unwrap().to_string()).unwrap();
|
||||
assert!(
|
||||
fresh[0].trashed,
|
||||
"reload_vault must reflect disk state after trashing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_vault_exists_false() {
|
||||
assert!(!check_vault_exists("/nonexistent/path/abc123".to_string()));
|
||||
|
||||
@@ -193,8 +193,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_float() {
|
||||
let v = FrontmatterValue::Number(3.14);
|
||||
assert_eq!(v.to_yaml_value(), "3.14");
|
||||
let v = FrontmatterValue::Number(3.125);
|
||||
assert_eq!(v.to_yaml_value(), "3.125");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use conflict::{
|
||||
};
|
||||
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
|
||||
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
|
||||
pub use remote::{git_pull, git_push, has_remote, GitPullResult};
|
||||
pub use remote::{git_pull, git_push, has_remote, GitPullResult, GitPushResult};
|
||||
pub use status::{get_modified_files, ModifiedFile};
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -44,7 +44,6 @@ pub fn init_repo(path: &str) -> Result<(), String> {
|
||||
std::fs::write(
|
||||
&gitignore_path,
|
||||
"# Laputa app files (machine-specific, never commit)\n\
|
||||
.laputa-cache.json\n\
|
||||
.laputa/settings.json\n\
|
||||
\n\
|
||||
# macOS\n\
|
||||
@@ -265,7 +264,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_creates_gitignore_with_ds_store() {
|
||||
fn test_init_repo_creates_gitignore() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
@@ -283,14 +282,15 @@ mod tests {
|
||||
content.contains(".DS_Store"),
|
||||
".gitignore should exclude .DS_Store"
|
||||
);
|
||||
assert!(
|
||||
content.contains(".laputa-cache.json"),
|
||||
".gitignore should exclude .laputa-cache.json"
|
||||
);
|
||||
assert!(
|
||||
content.contains(".laputa/settings.json"),
|
||||
".gitignore should exclude settings.json"
|
||||
);
|
||||
// Cache is now stored outside the vault — no need for .gitignore entry
|
||||
assert!(
|
||||
!content.contains(".laputa-cache.json"),
|
||||
".gitignore should NOT contain .laputa-cache.json (cache is external)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -54,7 +54,11 @@ fn parse_file_status(code: &str) -> &str {
|
||||
|
||||
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
|
||||
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
|
||||
pub fn get_vault_pulse(vault_path: &str, limit: usize, skip: usize) -> Result<Vec<PulseCommit>, String> {
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: &str,
|
||||
limit: usize,
|
||||
skip: usize,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !vault.join(".git").exists() {
|
||||
|
||||
@@ -110,8 +110,74 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPushResult {
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Classify a git push stderr message into a user-friendly status and message.
|
||||
pub fn classify_push_error(stderr: &str) -> GitPushResult {
|
||||
let lower = stderr.to_lowercase();
|
||||
|
||||
if lower.contains("non-fast-forward")
|
||||
|| lower.contains("[rejected]")
|
||||
|| lower.contains("fetch first")
|
||||
|| lower.contains("failed to push some refs")
|
||||
&& (lower.contains("updates were rejected") || lower.contains("non-fast-forward"))
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "rejected".to_string(),
|
||||
message: "Push rejected: remote has new commits. Pull first, then push.".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if lower.contains("authentication failed")
|
||||
|| lower.contains("could not read username")
|
||||
|| lower.contains("permission denied")
|
||||
|| lower.contains("403")
|
||||
|| lower.contains("invalid credentials")
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "auth_error".to_string(),
|
||||
message: "Push failed: authentication error. Check your credentials.".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if lower.contains("could not resolve host")
|
||||
|| lower.contains("unable to access")
|
||||
|| lower.contains("connection refused")
|
||||
|| lower.contains("network is unreachable")
|
||||
|| lower.contains("timed out")
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "network_error".to_string(),
|
||||
message: "Push failed: network error. Check your connection and try again.".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: extract the hint line if present, otherwise use the full stderr
|
||||
let hint_line = stderr
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("hint:"))
|
||||
.map(|l| l.trim_start().strip_prefix("hint:").unwrap_or(l).trim())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let detail = if hint_line.is_empty() {
|
||||
stderr.trim().to_string()
|
||||
} else {
|
||||
hint_line
|
||||
};
|
||||
|
||||
GitPushResult {
|
||||
status: "error".to_string(),
|
||||
message: format!("Push failed: {detail}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push to remote.
|
||||
pub fn git_push(vault_path: &str) -> Result<String, String> {
|
||||
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
@@ -122,13 +188,13 @@ pub fn git_push(vault_path: &str) -> Result<String, String> {
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git push failed: {}", stderr));
|
||||
return Ok(classify_push_error(&stderr));
|
||||
}
|
||||
|
||||
// git push often writes to stderr even on success
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Ok(format!("{}{}", stdout, stderr))
|
||||
Ok(GitPushResult {
|
||||
status: "ok".to_string(),
|
||||
message: "Pushed to remote".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -227,6 +293,104 @@ mod tests {
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_non_fast_forward() {
|
||||
let stderr = r#"To github.com:user/repo.git
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'github.com:user/repo.git'
|
||||
hint: Updates were rejected because the remote contains work that you do not
|
||||
hint: have locally."#;
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "rejected");
|
||||
assert!(result.message.contains("Pull first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_fetch_first() {
|
||||
let stderr = "error: failed to push some refs\nhint: Updates were rejected because the tip of your current branch is behind\nhint: its remote counterpart. Integrate the remote changes (e.g.\nhint: 'git pull ...') before pushing again.\nhint: See the 'Note about fast-forwards' in 'git push --help' for details.\n ! [rejected] main -> main (fetch first)\n";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_auth_failure() {
|
||||
let stderr = "remote: Permission denied to user/repo.git\nfatal: unable to access 'https://github.com/user/repo.git/': The requested URL returned error: 403";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "auth_error");
|
||||
assert!(result.message.contains("authentication"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_network() {
|
||||
let stderr = "fatal: unable to access 'https://github.com/user/repo.git/': Could not resolve host: github.com";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "network_error");
|
||||
assert!(result.message.contains("network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown() {
|
||||
let stderr = "error: something unexpected happened\nhint: Try again later";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "error");
|
||||
assert!(result.message.contains("Try again later"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown_no_hint() {
|
||||
let stderr = "error: something totally weird";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "error");
|
||||
assert!(result.message.contains("something totally weird"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_result_serialization() {
|
||||
let result = GitPushResult {
|
||||
status: "rejected".to_string(),
|
||||
message: "Push rejected".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"rejected\""));
|
||||
let parsed: GitPushResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.status, "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_success_returns_ok() {
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
let result = git_push(vp_a).unwrap();
|
||||
assert_eq!(result.status, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_rejected_returns_rejected() {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
// Both clones commit and push — second push should be rejected
|
||||
fs::write(clone_a.path().join("note.md"), "# A\n").unwrap();
|
||||
git_commit(vp_a, "from A").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
fs::write(clone_b.path().join("note.md"), "# B\n").unwrap();
|
||||
git_commit(vp_b, "from B").unwrap();
|
||||
git_push(vp_b).unwrap();
|
||||
|
||||
// Now A has a new commit but hasn't pulled B's changes
|
||||
fs::write(clone_a.path().join("other.md"), "# Other\n").unwrap();
|
||||
git_commit(vp_a, "from A again").unwrap();
|
||||
let result = git_push(vp_a).unwrap();
|
||||
assert_eq!(result.status, "rejected");
|
||||
assert!(result.message.contains("Pull first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_result_serialization() {
|
||||
let result = GitPullResult {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
@@ -223,10 +223,72 @@ pub struct IndexStatus {
|
||||
pub indexed_count: usize,
|
||||
pub embedded_count: usize,
|
||||
pub pending_embed: usize,
|
||||
pub last_indexed_commit: Option<String>,
|
||||
pub last_indexed_at: Option<u64>,
|
||||
}
|
||||
|
||||
// --- Index metadata persistence ---
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
struct IndexMetadata {
|
||||
#[serde(default)]
|
||||
last_indexed_commit: Option<String>,
|
||||
#[serde(default)]
|
||||
last_indexed_at: Option<u64>,
|
||||
}
|
||||
|
||||
fn index_metadata_path(vault_path: &str) -> PathBuf {
|
||||
Path::new(vault_path).join(".laputa-index.json")
|
||||
}
|
||||
|
||||
fn load_index_metadata(vault_path: &str) -> IndexMetadata {
|
||||
let path = index_metadata_path(vault_path);
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_index_metadata(vault_path: &str, meta: &IndexMetadata) -> Result<(), String> {
|
||||
let path = index_metadata_path(vault_path);
|
||||
let json =
|
||||
serde_json::to_string_pretty(meta).map_err(|e| format!("Failed to serialize: {e}"))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("Failed to write index metadata: {e}"))
|
||||
}
|
||||
|
||||
/// Get the current HEAD commit hash for a vault.
|
||||
fn get_head_commit(vault_path: &str) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the current HEAD as the last indexed commit.
|
||||
fn stamp_index_commit(vault_path: &str) {
|
||||
if let Some(commit) = get_head_commit(vault_path) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some(commit),
|
||||
last_indexed_at: Some(now),
|
||||
};
|
||||
let _ = save_index_metadata(vault_path, &meta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the vault has a qmd index and its status.
|
||||
pub fn check_index_status(vault_path: &str) -> IndexStatus {
|
||||
let meta = load_index_metadata(vault_path);
|
||||
|
||||
let qmd = match find_qmd_binary() {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
@@ -237,6 +299,8 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
|
||||
indexed_count: 0,
|
||||
embedded_count: 0,
|
||||
pending_embed: 0,
|
||||
last_indexed_commit: meta.last_indexed_commit,
|
||||
last_indexed_at: meta.last_indexed_at,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -244,7 +308,7 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
let output = qmd.command().args(["status"]).output();
|
||||
|
||||
match output {
|
||||
let mut status = match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
parse_status_for_vault(&stdout, &vault_name)
|
||||
@@ -256,8 +320,14 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
|
||||
indexed_count: 0,
|
||||
embedded_count: 0,
|
||||
pending_embed: 0,
|
||||
last_indexed_commit: None,
|
||||
last_indexed_at: None,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
status.last_indexed_commit = meta.last_indexed_commit;
|
||||
status.last_indexed_at = meta.last_indexed_at;
|
||||
status
|
||||
}
|
||||
|
||||
fn vault_dir_name(vault_path: &str) -> String {
|
||||
@@ -323,6 +393,8 @@ fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus
|
||||
indexed_count,
|
||||
embedded_count,
|
||||
pending_embed,
|
||||
last_indexed_commit: None,
|
||||
last_indexed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +464,9 @@ where
|
||||
|
||||
ensure_collection(vault_path)?;
|
||||
|
||||
// Phase 1: update (scan files)
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
|
||||
// Phase 1: update (scan files) — scoped to this vault's collection only
|
||||
on_progress(IndexingProgress {
|
||||
phase: "scanning".to_string(),
|
||||
current: 0,
|
||||
@@ -403,7 +477,7 @@ where
|
||||
|
||||
let update_output = qmd
|
||||
.command()
|
||||
.args(["update"])
|
||||
.args(["update", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd update failed: {e}"))?;
|
||||
|
||||
@@ -432,7 +506,7 @@ where
|
||||
error: None,
|
||||
});
|
||||
|
||||
// Phase 2: embed (generate vectors)
|
||||
// Phase 2: embed (generate vectors) — scoped to this vault's collection only
|
||||
on_progress(IndexingProgress {
|
||||
phase: "embedding".to_string(),
|
||||
current: 0,
|
||||
@@ -443,7 +517,7 @@ where
|
||||
|
||||
let embed_output = qmd
|
||||
.command()
|
||||
.args(["embed"])
|
||||
.args(["embed", "-c", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd embed failed: {e}"))?;
|
||||
|
||||
@@ -451,6 +525,7 @@ where
|
||||
let stderr = String::from_utf8_lossy(&embed_output.stderr);
|
||||
// Embedding failure is non-fatal — keyword search still works
|
||||
log::warn!("qmd embed failed (keyword search still works): {stderr}");
|
||||
stamp_index_commit(vault_path);
|
||||
on_progress(IndexingProgress {
|
||||
phase: "complete".to_string(),
|
||||
current: total,
|
||||
@@ -461,6 +536,8 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
stamp_index_commit(vault_path);
|
||||
|
||||
on_progress(IndexingProgress {
|
||||
phase: "complete".to_string(),
|
||||
current: total,
|
||||
@@ -504,7 +581,7 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
|
||||
|
||||
let output = qmd
|
||||
.command()
|
||||
.args(["update"])
|
||||
.args(["update", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd incremental update failed: {e}"))?;
|
||||
|
||||
@@ -513,9 +590,23 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
|
||||
return Err(format!("qmd update failed: {stderr}"));
|
||||
}
|
||||
|
||||
stamp_index_commit(vault_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if HEAD has advanced past the last indexed commit.
|
||||
#[cfg(test)]
|
||||
fn needs_reindex_after_sync(vault_path: &str) -> bool {
|
||||
let meta = load_index_metadata(vault_path);
|
||||
let head = get_head_commit(vault_path);
|
||||
match (meta.last_indexed_commit, head) {
|
||||
(Some(last), Some(current)) => last != current,
|
||||
(None, Some(_)) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -698,4 +789,126 @@ Collections
|
||||
// It verifies the function doesn't panic.
|
||||
let _ = find_bun();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_metadata_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
// Default when no file exists
|
||||
let meta = load_index_metadata(vault);
|
||||
assert!(meta.last_indexed_commit.is_none());
|
||||
assert!(meta.last_indexed_at.is_none());
|
||||
|
||||
// Write and read back
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some("abc123def456".to_string()),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
let loaded = load_index_metadata(vault);
|
||||
assert_eq!(loaded.last_indexed_commit.as_deref(), Some("abc123def456"));
|
||||
assert_eq!(loaded.last_indexed_at, Some(1709000000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_metadata_survives_malformed_json() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
// Write garbage
|
||||
std::fs::write(dir.path().join(".laputa-index.json"), "not json").unwrap();
|
||||
|
||||
let meta = load_index_metadata(vault);
|
||||
assert!(meta.last_indexed_commit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reindex_after_sync_no_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
// Init a git repo so get_head_commit works
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// No metadata → needs reindex
|
||||
assert!(needs_reindex_after_sync(vault));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reindex_after_sync_same_commit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let head = get_head_commit(vault).unwrap();
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some(head),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
assert!(!needs_reindex_after_sync(vault));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reindex_after_sync_different_commit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some("old_commit_hash".to_string()),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
assert!(needs_reindex_after_sync(vault));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_index_status_includes_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some("abc123".to_string()),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
let status = check_index_status(vault);
|
||||
assert_eq!(status.last_indexed_commit.as_deref(), Some("abc123"));
|
||||
assert_eq!(status.last_indexed_at, Some(1709000000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ fn run_startup_tasks() {
|
||||
// Seed type/theme.md so the Theme type has an icon in the sidebar
|
||||
let _ = theme::ensure_theme_type_definition(vp_str);
|
||||
|
||||
// Migrate root AGENTS.md → config/agents.md (one-time, idempotent)
|
||||
vault::migrate_agents_md(vp_str);
|
||||
// Seed config/ with default config files if missing
|
||||
vault::seed_config_files(vp_str);
|
||||
|
||||
// Register Laputa MCP server in Claude Code and Cursor configs
|
||||
match mcp::register_mcp(vp_str) {
|
||||
Ok(status) => log::info!("MCP registration: {status}"),
|
||||
@@ -113,6 +118,7 @@ pub fn run() {
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::move_note_to_type_folder,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
@@ -131,10 +137,14 @@ pub fn run() {
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::purge_trash,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::empty_trash,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::batch_archive_notes,
|
||||
commands::batch_trash_notes,
|
||||
@@ -167,6 +177,7 @@ pub fn run() {
|
||||
commands::create_vault_theme,
|
||||
commands::ensure_vault_themes,
|
||||
commands::restore_default_themes,
|
||||
commands::repair_vault,
|
||||
commands::get_vault_config,
|
||||
commands::save_vault_config
|
||||
])
|
||||
|
||||
@@ -13,6 +13,7 @@ const FILE_DAILY_NOTE: &str = "file-daily-note";
|
||||
const FILE_QUICK_OPEN: &str = "file-quick-open";
|
||||
const FILE_SAVE: &str = "file-save";
|
||||
const FILE_CLOSE_TAB: &str = "file-close-tab";
|
||||
const FILE_REOPEN_CLOSED_TAB: &str = "file-reopen-closed-tab";
|
||||
|
||||
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
|
||||
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
|
||||
@@ -32,13 +33,13 @@ const VIEW_GO_BACK: &str = "view-go-back";
|
||||
const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
|
||||
const GO_ALL_NOTES: &str = "go-all-notes";
|
||||
const GO_FAVORITES: &str = "go-favorites";
|
||||
const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_TRASH: &str = "go-trash";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_TRASH: &str = "note-trash";
|
||||
const NOTE_EMPTY_TRASH: &str = "note-empty-trash";
|
||||
|
||||
const VAULT_OPEN: &str = "vault-open";
|
||||
const VAULT_REMOVE: &str = "vault-remove";
|
||||
@@ -49,6 +50,9 @@ const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
|
||||
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
|
||||
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
|
||||
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
|
||||
const VAULT_REINDEX: &str = "vault-reindex";
|
||||
const VAULT_RELOAD: &str = "vault-reload";
|
||||
const VAULT_REPAIR: &str = "vault-repair";
|
||||
|
||||
const CUSTOM_IDS: &[&str] = &[
|
||||
APP_SETTINGS,
|
||||
@@ -59,6 +63,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
FILE_CLOSE_TAB,
|
||||
FILE_REOPEN_CLOSED_TAB,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
@@ -75,12 +80,12 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_FAVORITES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
VAULT_RESTORE_GETTING_STARTED,
|
||||
@@ -90,6 +95,9 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VAULT_RESOLVE_CONFLICTS,
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
VAULT_REINDEX,
|
||||
VAULT_RELOAD,
|
||||
VAULT_REPAIR,
|
||||
];
|
||||
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
@@ -161,6 +169,10 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.id(FILE_CLOSE_TAB)
|
||||
.accelerator("CmdOrCtrl+W")
|
||||
.build(app)?;
|
||||
let reopen_closed_tab = MenuItemBuilder::new("Reopen Closed Tab")
|
||||
.id(FILE_REOPEN_CLOSED_TAB)
|
||||
.accelerator("CmdOrCtrl+Shift+T")
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "File")
|
||||
.item(&new_note)
|
||||
@@ -170,6 +182,7 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.separator()
|
||||
.item(&save)
|
||||
.item(&close_tab)
|
||||
.item(&reopen_closed_tab)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
@@ -249,9 +262,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
let all_notes = MenuItemBuilder::new("All Notes")
|
||||
.id(GO_ALL_NOTES)
|
||||
.build(app)?;
|
||||
let favorites = MenuItemBuilder::new("Favorites")
|
||||
.id(GO_FAVORITES)
|
||||
.build(app)?;
|
||||
let archived = MenuItemBuilder::new("Archived")
|
||||
.id(GO_ARCHIVED)
|
||||
.build(app)?;
|
||||
@@ -268,7 +278,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Go")
|
||||
.item(&all_notes)
|
||||
.item(&favorites)
|
||||
.item(&archived)
|
||||
.item(&trash)
|
||||
.item(&changes)
|
||||
@@ -287,6 +296,9 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.id(NOTE_TRASH)
|
||||
.accelerator("CmdOrCtrl+Backspace")
|
||||
.build(app)?;
|
||||
let empty_trash = MenuItemBuilder::new("Empty Trash…")
|
||||
.id(NOTE_EMPTY_TRASH)
|
||||
.build(app)?;
|
||||
let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor")
|
||||
.id(EDIT_TOGGLE_RAW_EDITOR)
|
||||
.accelerator("CmdOrCtrl+\\")
|
||||
@@ -302,6 +314,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
Ok(SubmenuBuilder::new(app, "Note")
|
||||
.item(&archive_note)
|
||||
.item(&trash_note)
|
||||
.item(&empty_trash)
|
||||
.separator()
|
||||
.item(&toggle_raw_editor)
|
||||
.item(&toggle_ai_chat)
|
||||
@@ -335,9 +348,18 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
let view_changes = MenuItemBuilder::new("View Pending Changes")
|
||||
.id(VAULT_VIEW_CHANGES)
|
||||
.build(app)?;
|
||||
let install_mcp = MenuItemBuilder::new("Install MCP Server")
|
||||
let install_mcp = MenuItemBuilder::new("Restore MCP Server")
|
||||
.id(VAULT_INSTALL_MCP)
|
||||
.build(app)?;
|
||||
let reindex = MenuItemBuilder::new("Reindex Vault")
|
||||
.id(VAULT_REINDEX)
|
||||
.build(app)?;
|
||||
let reload = MenuItemBuilder::new("Reload Vault")
|
||||
.id(VAULT_RELOAD)
|
||||
.build(app)?;
|
||||
let repair = MenuItemBuilder::new("Repair Vault")
|
||||
.id(VAULT_REPAIR)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Vault")
|
||||
.item(&open_vault)
|
||||
@@ -351,6 +373,9 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
.item(&resolve_conflicts)
|
||||
.item(&view_changes)
|
||||
.separator()
|
||||
.item(&reindex)
|
||||
.item(&reload)
|
||||
.item(&repair)
|
||||
.item(&install_mcp)
|
||||
.build()?)
|
||||
}
|
||||
@@ -454,12 +479,12 @@ mod tests {
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_FAVORITES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
VAULT_RESTORE_GETTING_STARTED,
|
||||
@@ -469,6 +494,8 @@ mod tests {
|
||||
VAULT_RESOLVE_CONFLICTS,
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
VAULT_REINDEX,
|
||||
VAULT_RELOAD,
|
||||
];
|
||||
for id in &expected {
|
||||
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::indexing;
|
||||
use crate::vault;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -160,16 +161,19 @@ pub fn search_vault(
|
||||
|
||||
let results: Vec<SearchResult> = qmd_results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
.filter_map(|r| {
|
||||
let path = qmd_uri_to_vault_path(&r.file, vault_path);
|
||||
if vault::is_file_trashed(Path::new(&path)) {
|
||||
return None;
|
||||
}
|
||||
let snippet = extract_clean_snippet(&r.snippet);
|
||||
SearchResult {
|
||||
Some(SearchResult {
|
||||
title: r.title,
|
||||
path,
|
||||
snippet,
|
||||
score: r.score,
|
||||
note_type: None,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -93,7 +93,8 @@ fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String {
|
||||
fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String {
|
||||
let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n");
|
||||
for (key, value) in vars {
|
||||
if value.contains('#') || value.contains('\'') || value.contains(',') {
|
||||
if value.contains('#') || value.contains('\'') || value.contains(',') || value.contains('(')
|
||||
{
|
||||
fm.push_str(&format!("{key}: \"{value}\"\n"));
|
||||
} else {
|
||||
fm.push_str(&format!("{key}: {value}\n"));
|
||||
@@ -216,4 +217,85 @@ mod tests {
|
||||
assert_eq!(slugify("default"), "default");
|
||||
assert_eq!(slugify("Dark Mode!"), "dark-mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_theme_contains_all_default_css_vars() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
let path = create_vault_theme(vp, Some("Full Theme")).unwrap();
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
|
||||
// Every entry in DEFAULT_VAULT_THEME_VARS must appear in the generated file
|
||||
for (key, _) in &DEFAULT_VAULT_THEME_VARS {
|
||||
assert!(
|
||||
content.contains(&format!("{key}:")),
|
||||
"missing key in theme file: {key}"
|
||||
);
|
||||
}
|
||||
|
||||
// Spot-check editor properties from theme.json that were previously missing
|
||||
assert!(
|
||||
content.contains("editor-font-family:"),
|
||||
"missing editor-font-family"
|
||||
);
|
||||
assert!(
|
||||
content.contains("editor-padding-horizontal:"),
|
||||
"missing editor-padding-horizontal"
|
||||
);
|
||||
assert!(
|
||||
content.contains("headings-h1-font-size:"),
|
||||
"missing headings-h1-font-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("lists-bullet-size:"),
|
||||
"missing lists-bullet-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("lists-bullet-color:"),
|
||||
"missing lists-bullet-color"
|
||||
);
|
||||
assert!(
|
||||
content.contains("checkboxes-size:"),
|
||||
"missing checkboxes-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("inline-styles-bold-font-weight:"),
|
||||
"missing inline-styles-bold-font-weight"
|
||||
);
|
||||
assert!(
|
||||
content.contains("code-blocks-font-family:"),
|
||||
"missing code-blocks-font-family"
|
||||
);
|
||||
assert!(
|
||||
content.contains("blockquote-border-left-width:"),
|
||||
"missing blockquote-border-left-width"
|
||||
);
|
||||
assert!(
|
||||
content.contains("table-border-color:"),
|
||||
"missing table-border-color"
|
||||
);
|
||||
assert!(
|
||||
content.contains("horizontal-rule-thickness:"),
|
||||
"missing horizontal-rule-thickness"
|
||||
);
|
||||
assert!(content.contains("colors-text:"), "missing colors-text");
|
||||
assert!(content.contains("colors-cursor:"), "missing colors-cursor");
|
||||
|
||||
// Numeric values that need CSS units must have px suffix
|
||||
assert!(
|
||||
content.contains("editor-font-size: 15px"),
|
||||
"editor-font-size should have px unit"
|
||||
);
|
||||
assert!(
|
||||
content.contains("editor-max-width: 720px"),
|
||||
"editor-max-width should have px unit"
|
||||
);
|
||||
assert!(
|
||||
content.contains("editor-padding-horizontal: 40px"),
|
||||
"editor-padding-horizontal should have px unit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +103,16 @@ pub const MINIMAL_THEME: &str = r##"{
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// CSS variable key-value pairs for the default light vault theme.
|
||||
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
// shadcn/ui base
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault-based theme notes (markdown with frontmatter CSS custom properties)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Complete set of CSS variable key-value pairs for the default light vault theme.
|
||||
/// Includes both UI chrome colours and all editor styling properties from theme.json.
|
||||
/// Numeric values that need CSS units include the `px` suffix; unitless values
|
||||
/// (line-height, font-weight) are bare numbers.
|
||||
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 140] = [
|
||||
// ── shadcn/ui base colours ──────────────────────────────────────────
|
||||
("background", "#FFFFFF"),
|
||||
("foreground", "#37352F"),
|
||||
("card", "#FFFFFF"),
|
||||
@@ -126,19 +133,21 @@ pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
("sidebar-foreground", "#37352F"),
|
||||
("sidebar-border", "#E9E9E7"),
|
||||
("sidebar-accent", "#EBEBEA"),
|
||||
// Text hierarchy
|
||||
// ── Text hierarchy ──────────────────────────────────────────────────
|
||||
("text-primary", "#37352F"),
|
||||
("text-secondary", "#787774"),
|
||||
("text-tertiary", "#B4B4B4"),
|
||||
("text-muted", "#B4B4B4"),
|
||||
("text-heading", "#37352F"),
|
||||
// Backgrounds
|
||||
// ── Backgrounds ─────────────────────────────────────────────────────
|
||||
("bg-primary", "#FFFFFF"),
|
||||
("bg-card", "#FFFFFF"),
|
||||
("bg-sidebar", "#F7F6F3"),
|
||||
("bg-hover", "#EBEBEA"),
|
||||
("bg-hover-subtle", "#F0F0EF"),
|
||||
("bg-selected", "#E8F4FE"),
|
||||
("border-primary", "#E9E9E7"),
|
||||
// Accent colours
|
||||
// ── Accent colours ──────────────────────────────────────────────────
|
||||
("accent-blue", "#155DFF"),
|
||||
("accent-green", "#00B38B"),
|
||||
("accent-orange", "#D9730D"),
|
||||
@@ -150,189 +159,290 @@ pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
("accent-purple-light", "#A932FF14"),
|
||||
("accent-red-light", "#E03E3E14"),
|
||||
("accent-yellow-light", "#F0B10014"),
|
||||
// Typography
|
||||
// ── Typography base ─────────────────────────────────────────────────
|
||||
(
|
||||
"font-family",
|
||||
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
),
|
||||
("font-size-base", "14px"),
|
||||
// Editor
|
||||
("editor-font-size", "16"),
|
||||
// ── Editor (from theme.json → editor) ───────────────────────────────
|
||||
(
|
||||
"editor-font-family",
|
||||
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
),
|
||||
("editor-font-size", "15px"),
|
||||
("editor-line-height", "1.5"),
|
||||
("editor-max-width", "720"),
|
||||
("editor-max-width", "720px"),
|
||||
("editor-padding-horizontal", "40px"),
|
||||
("editor-padding-vertical", "20px"),
|
||||
("editor-paragraph-spacing", "8px"),
|
||||
// ── Headings H1 ────────────────────────────────────────────────────
|
||||
("headings-h1-font-size", "32px"),
|
||||
("headings-h1-font-weight", "700"),
|
||||
("headings-h1-line-height", "1.2"),
|
||||
("headings-h1-margin-top", "32px"),
|
||||
("headings-h1-margin-bottom", "12px"),
|
||||
("headings-h1-color", "var(--text-heading)"),
|
||||
("headings-h1-letter-spacing", "-0.5px"),
|
||||
// ── Headings H2 ────────────────────────────────────────────────────
|
||||
("headings-h2-font-size", "27px"),
|
||||
("headings-h2-font-weight", "600"),
|
||||
("headings-h2-line-height", "1.4"),
|
||||
("headings-h2-margin-top", "28px"),
|
||||
("headings-h2-margin-bottom", "10px"),
|
||||
("headings-h2-color", "var(--text-heading)"),
|
||||
("headings-h2-letter-spacing", "-0.5px"),
|
||||
// ── Headings H3 ────────────────────────────────────────────────────
|
||||
("headings-h3-font-size", "20px"),
|
||||
("headings-h3-font-weight", "600"),
|
||||
("headings-h3-line-height", "1.4"),
|
||||
("headings-h3-margin-top", "24px"),
|
||||
("headings-h3-margin-bottom", "8px"),
|
||||
("headings-h3-color", "var(--text-heading)"),
|
||||
("headings-h3-letter-spacing", "-0.5px"),
|
||||
// ── Headings H4 ────────────────────────────────────────────────────
|
||||
("headings-h4-font-size", "20px"),
|
||||
("headings-h4-font-weight", "600"),
|
||||
("headings-h4-line-height", "1.4"),
|
||||
("headings-h4-margin-top", "20px"),
|
||||
("headings-h4-margin-bottom", "6px"),
|
||||
("headings-h4-color", "var(--text-heading)"),
|
||||
("headings-h4-letter-spacing", "0px"),
|
||||
// ── Lists ───────────────────────────────────────────────────────────
|
||||
("lists-bullet-size", "28px"),
|
||||
("lists-bullet-color", "#177bfd"),
|
||||
("lists-indent-size", "24px"),
|
||||
("lists-item-spacing", "4px"),
|
||||
("lists-padding-left", "8px"),
|
||||
("lists-bullet-gap", "6px"),
|
||||
// ── Checkboxes ──────────────────────────────────────────────────────
|
||||
("checkboxes-size", "18px"),
|
||||
("checkboxes-border-radius", "3px"),
|
||||
("checkboxes-checked-color", "var(--accent-blue)"),
|
||||
("checkboxes-unchecked-border-color", "var(--text-muted)"),
|
||||
("checkboxes-gap", "8px"),
|
||||
// ── Inline styles: bold ─────────────────────────────────────────────
|
||||
("inline-styles-bold-font-weight", "700"),
|
||||
("inline-styles-bold-color", "var(--text-primary)"),
|
||||
// ── Inline styles: italic ───────────────────────────────────────────
|
||||
("inline-styles-italic-font-style", "italic"),
|
||||
("inline-styles-italic-color", "var(--text-primary)"),
|
||||
// ── Inline styles: strikethrough ────────────────────────────────────
|
||||
("inline-styles-strikethrough-color", "var(--text-tertiary)"),
|
||||
(
|
||||
"inline-styles-strikethrough-text-decoration",
|
||||
"line-through",
|
||||
),
|
||||
// ── Inline styles: code ─────────────────────────────────────────────
|
||||
(
|
||||
"inline-styles-code-font-family",
|
||||
"'SF Mono', 'Fira Code', monospace",
|
||||
),
|
||||
("inline-styles-code-font-size", "14px"),
|
||||
(
|
||||
"inline-styles-code-background-color",
|
||||
"var(--bg-hover-subtle)",
|
||||
),
|
||||
("inline-styles-code-padding-horizontal", "4px"),
|
||||
("inline-styles-code-padding-vertical", "2px"),
|
||||
("inline-styles-code-border-radius", "3px"),
|
||||
("inline-styles-code-color", "var(--text-secondary)"),
|
||||
// ── Inline styles: link ─────────────────────────────────────────────
|
||||
("inline-styles-link-color", "var(--accent-blue)"),
|
||||
("inline-styles-link-text-decoration", "underline"),
|
||||
// ── Inline styles: wikilink ─────────────────────────────────────────
|
||||
("inline-styles-wikilink-color", "var(--accent-blue)"),
|
||||
("inline-styles-wikilink-text-decoration", "none"),
|
||||
(
|
||||
"inline-styles-wikilink-border-bottom",
|
||||
"1px dotted currentColor",
|
||||
),
|
||||
("inline-styles-wikilink-cursor", "pointer"),
|
||||
// ── Code blocks ─────────────────────────────────────────────────────
|
||||
(
|
||||
"code-blocks-font-family",
|
||||
"'SF Mono', 'Fira Code', monospace",
|
||||
),
|
||||
("code-blocks-font-size", "13px"),
|
||||
("code-blocks-line-height", "1.5"),
|
||||
("code-blocks-background-color", "var(--bg-card)"),
|
||||
("code-blocks-padding-horizontal", "16px"),
|
||||
("code-blocks-padding-vertical", "12px"),
|
||||
("code-blocks-border-radius", "6px"),
|
||||
("code-blocks-margin-vertical", "12px"),
|
||||
// ── Blockquote ──────────────────────────────────────────────────────
|
||||
("blockquote-border-left-width", "3px"),
|
||||
("blockquote-border-left-color", "var(--accent-blue)"),
|
||||
("blockquote-padding-left", "16px"),
|
||||
("blockquote-margin-vertical", "12px"),
|
||||
("blockquote-color", "var(--text-secondary)"),
|
||||
("blockquote-font-style", "italic"),
|
||||
// ── Table ───────────────────────────────────────────────────────────
|
||||
("table-border-color", "var(--border-primary)"),
|
||||
("table-header-background", "var(--bg-card)"),
|
||||
("table-cell-padding-horizontal", "12px"),
|
||||
("table-cell-padding-vertical", "8px"),
|
||||
("table-font-size", "14px"),
|
||||
// ── Horizontal rule ─────────────────────────────────────────────────
|
||||
("horizontal-rule-color", "var(--border-primary)"),
|
||||
("horizontal-rule-margin-vertical", "24px"),
|
||||
("horizontal-rule-thickness", "1px"),
|
||||
// ── Colors (semantic aliases from theme.json → colors) ──────────────
|
||||
("colors-background", "var(--bg-primary)"),
|
||||
("colors-text", "var(--text-primary)"),
|
||||
("colors-text-secondary", "var(--text-secondary)"),
|
||||
("colors-text-muted", "var(--text-muted)"),
|
||||
("colors-heading", "var(--text-heading)"),
|
||||
("colors-accent", "var(--accent-blue)"),
|
||||
("colors-selection", "var(--bg-selected)"),
|
||||
("colors-cursor", "var(--text-primary)"),
|
||||
];
|
||||
|
||||
/// Vault-based theme note for the built-in Default theme.
|
||||
pub const DEFAULT_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: Light theme with warm, paper-like tones\n\
|
||||
background: \"#FFFFFF\"\n\
|
||||
foreground: \"#37352F\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#EBEBEA\"\n\
|
||||
secondary-foreground: \"#37352F\"\n\
|
||||
muted: \"#F0F0EF\"\n\
|
||||
muted-foreground: \"#787774\"\n\
|
||||
accent: \"#EBEBEA\"\n\
|
||||
accent-foreground: \"#37352F\"\n\
|
||||
destructive: \"#E03E3E\"\n\
|
||||
border: \"#E9E9E7\"\n\
|
||||
input: \"#E9E9E7\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#F7F6F3\"\n\
|
||||
sidebar-foreground: \"#37352F\"\n\
|
||||
sidebar-border: \"#E9E9E7\"\n\
|
||||
sidebar-accent: \"#EBEBEA\"\n\
|
||||
text-primary: \"#37352F\"\n\
|
||||
text-secondary: \"#787774\"\n\
|
||||
text-muted: \"#B4B4B4\"\n\
|
||||
text-heading: \"#37352F\"\n\
|
||||
bg-primary: \"#FFFFFF\"\n\
|
||||
bg-sidebar: \"#F7F6F3\"\n\
|
||||
bg-hover: \"#EBEBEA\"\n\
|
||||
bg-hover-subtle: \"#F0F0EF\"\n\
|
||||
bg-selected: \"#E8F4FE\"\n\
|
||||
border-primary: \"#E9E9E7\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#E03E3E\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF14\"\n\
|
||||
accent-green-light: \"#00B38B14\"\n\
|
||||
accent-purple-light: \"#A932FF14\"\n\
|
||||
accent-red-light: \"#E03E3E14\"\n\
|
||||
accent-yellow-light: \"#F0B10014\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Default Theme\n\
|
||||
\n\
|
||||
The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
|
||||
/// UI-colour overrides for the Dark vault theme (keys that differ from default).
|
||||
const DARK_COLOR_OVERRIDES: &[(&str, &str)] = &[
|
||||
("background", "#0f0f1a"),
|
||||
("foreground", "#e0e0e0"),
|
||||
("card", "#16162a"),
|
||||
("popover", "#1e1e3a"),
|
||||
("secondary", "#2a2a4a"),
|
||||
("secondary-foreground", "#e0e0e0"),
|
||||
("muted", "#1e1e3a"),
|
||||
("muted-foreground", "#888888"),
|
||||
("accent", "#2a2a4a"),
|
||||
("accent-foreground", "#e0e0e0"),
|
||||
("destructive", "#f44336"),
|
||||
("border", "#2a2a4a"),
|
||||
("input", "#2a2a4a"),
|
||||
("sidebar", "#1a1a2e"),
|
||||
("sidebar-foreground", "#e0e0e0"),
|
||||
("sidebar-border", "#2a2a4a"),
|
||||
("sidebar-accent", "#2a2a4a"),
|
||||
("text-primary", "#e0e0e0"),
|
||||
("text-secondary", "#888888"),
|
||||
("text-tertiary", "#666666"),
|
||||
("text-muted", "#666666"),
|
||||
("text-heading", "#e0e0e0"),
|
||||
("bg-primary", "#0f0f1a"),
|
||||
("bg-card", "#16162a"),
|
||||
("bg-sidebar", "#1a1a2e"),
|
||||
("bg-hover", "#2a2a4a"),
|
||||
("bg-hover-subtle", "#1e1e3a"),
|
||||
("bg-selected", "#155DFF22"),
|
||||
("border-primary", "#2a2a4a"),
|
||||
("accent-red", "#f44336"),
|
||||
("accent-blue-light", "#155DFF33"),
|
||||
("accent-green-light", "#00B38B33"),
|
||||
("accent-purple-light", "#A932FF33"),
|
||||
("accent-red-light", "#f4433633"),
|
||||
("accent-yellow-light", "#F0B10033"),
|
||||
("lists-bullet-color", "#155DFF"),
|
||||
];
|
||||
|
||||
/// Vault-based theme note for the built-in Dark theme.
|
||||
pub const DARK_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: Dark variant with deep navy tones\n\
|
||||
background: \"#0f0f1a\"\n\
|
||||
foreground: \"#e0e0e0\"\n\
|
||||
card: \"#16162a\"\n\
|
||||
popover: \"#1e1e3a\"\n\
|
||||
primary: \"#155DFF\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#2a2a4a\"\n\
|
||||
secondary-foreground: \"#e0e0e0\"\n\
|
||||
muted: \"#1e1e3a\"\n\
|
||||
muted-foreground: \"#888888\"\n\
|
||||
accent: \"#2a2a4a\"\n\
|
||||
accent-foreground: \"#e0e0e0\"\n\
|
||||
destructive: \"#f44336\"\n\
|
||||
border: \"#2a2a4a\"\n\
|
||||
input: \"#2a2a4a\"\n\
|
||||
ring: \"#155DFF\"\n\
|
||||
sidebar: \"#1a1a2e\"\n\
|
||||
sidebar-foreground: \"#e0e0e0\"\n\
|
||||
sidebar-border: \"#2a2a4a\"\n\
|
||||
sidebar-accent: \"#2a2a4a\"\n\
|
||||
text-primary: \"#e0e0e0\"\n\
|
||||
text-secondary: \"#888888\"\n\
|
||||
text-muted: \"#666666\"\n\
|
||||
text-heading: \"#e0e0e0\"\n\
|
||||
bg-primary: \"#0f0f1a\"\n\
|
||||
bg-sidebar: \"#1a1a2e\"\n\
|
||||
bg-hover: \"#2a2a4a\"\n\
|
||||
bg-hover-subtle: \"#1e1e3a\"\n\
|
||||
bg-selected: \"#155DFF22\"\n\
|
||||
border-primary: \"#2a2a4a\"\n\
|
||||
accent-blue: \"#155DFF\"\n\
|
||||
accent-green: \"#00B38B\"\n\
|
||||
accent-orange: \"#D9730D\"\n\
|
||||
accent-red: \"#f44336\"\n\
|
||||
accent-purple: \"#A932FF\"\n\
|
||||
accent-yellow: \"#F0B100\"\n\
|
||||
accent-blue-light: \"#155DFF33\"\n\
|
||||
accent-green-light: \"#00B38B33\"\n\
|
||||
accent-purple-light: \"#A932FF33\"\n\
|
||||
accent-red-light: \"#f4433633\"\n\
|
||||
accent-yellow-light: \"#F0B10033\"\n\
|
||||
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
|
||||
font-size-base: 14px\n\
|
||||
editor-font-size: 16\n\
|
||||
editor-line-height: 1.5\n\
|
||||
editor-max-width: 720\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Dark Theme\n\
|
||||
\n\
|
||||
A dark theme with deep navy tones for comfortable night-time reading.\n";
|
||||
/// UI-colour + editor-property overrides for the Minimal vault theme.
|
||||
const MINIMAL_OVERRIDES: &[(&str, &str)] = &[
|
||||
("background", "#FAFAFA"),
|
||||
("foreground", "#111111"),
|
||||
("primary", "#000000"),
|
||||
("secondary", "#F0F0F0"),
|
||||
("secondary-foreground", "#111111"),
|
||||
("muted", "#F5F5F5"),
|
||||
("muted-foreground", "#666666"),
|
||||
("accent", "#F0F0F0"),
|
||||
("accent-foreground", "#111111"),
|
||||
("destructive", "#CC0000"),
|
||||
("border", "#E0E0E0"),
|
||||
("input", "#E0E0E0"),
|
||||
("ring", "#000000"),
|
||||
("sidebar", "#F5F5F5"),
|
||||
("sidebar-foreground", "#111111"),
|
||||
("sidebar-border", "#E0E0E0"),
|
||||
("sidebar-accent", "#E8E8E8"),
|
||||
("text-primary", "#111111"),
|
||||
("text-secondary", "#666666"),
|
||||
("text-tertiary", "#999999"),
|
||||
("text-muted", "#999999"),
|
||||
("text-heading", "#111111"),
|
||||
("bg-primary", "#FAFAFA"),
|
||||
("bg-card", "#FFFFFF"),
|
||||
("bg-sidebar", "#F5F5F5"),
|
||||
("bg-hover", "#EBEBEB"),
|
||||
("bg-hover-subtle", "#F5F5F5"),
|
||||
("bg-selected", "#00000014"),
|
||||
("border-primary", "#E0E0E0"),
|
||||
("accent-blue", "#000000"),
|
||||
("accent-green", "#006600"),
|
||||
("accent-orange", "#996600"),
|
||||
("accent-red", "#CC0000"),
|
||||
("accent-purple", "#660099"),
|
||||
("accent-yellow", "#996600"),
|
||||
("accent-blue-light", "#00000014"),
|
||||
("accent-green-light", "#00660014"),
|
||||
("accent-purple-light", "#66009914"),
|
||||
("accent-red-light", "#CC000014"),
|
||||
("accent-yellow-light", "#99660014"),
|
||||
("font-family", "'SF Mono', 'Menlo', monospace"),
|
||||
("font-size-base", "13px"),
|
||||
("editor-font-size", "15px"),
|
||||
("editor-line-height", "1.6"),
|
||||
("editor-max-width", "680px"),
|
||||
("lists-bullet-color", "#000000"),
|
||||
];
|
||||
|
||||
/// Vault-based theme note for the built-in Minimal theme.
|
||||
pub const MINIMAL_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
Description: High contrast, minimal chrome\n\
|
||||
background: \"#FAFAFA\"\n\
|
||||
foreground: \"#111111\"\n\
|
||||
card: \"#FFFFFF\"\n\
|
||||
popover: \"#FFFFFF\"\n\
|
||||
primary: \"#000000\"\n\
|
||||
primary-foreground: \"#FFFFFF\"\n\
|
||||
secondary: \"#F0F0F0\"\n\
|
||||
secondary-foreground: \"#111111\"\n\
|
||||
muted: \"#F5F5F5\"\n\
|
||||
muted-foreground: \"#666666\"\n\
|
||||
accent: \"#F0F0F0\"\n\
|
||||
accent-foreground: \"#111111\"\n\
|
||||
destructive: \"#CC0000\"\n\
|
||||
border: \"#E0E0E0\"\n\
|
||||
input: \"#E0E0E0\"\n\
|
||||
ring: \"#000000\"\n\
|
||||
sidebar: \"#F5F5F5\"\n\
|
||||
sidebar-foreground: \"#111111\"\n\
|
||||
sidebar-border: \"#E0E0E0\"\n\
|
||||
sidebar-accent: \"#E8E8E8\"\n\
|
||||
text-primary: \"#111111\"\n\
|
||||
text-secondary: \"#666666\"\n\
|
||||
text-muted: \"#999999\"\n\
|
||||
text-heading: \"#111111\"\n\
|
||||
bg-primary: \"#FAFAFA\"\n\
|
||||
bg-sidebar: \"#F5F5F5\"\n\
|
||||
bg-hover: \"#EBEBEB\"\n\
|
||||
bg-hover-subtle: \"#F5F5F5\"\n\
|
||||
bg-selected: \"#00000014\"\n\
|
||||
border-primary: \"#E0E0E0\"\n\
|
||||
accent-blue: \"#000000\"\n\
|
||||
accent-green: \"#006600\"\n\
|
||||
accent-orange: \"#996600\"\n\
|
||||
accent-red: \"#CC0000\"\n\
|
||||
accent-purple: \"#660099\"\n\
|
||||
accent-yellow: \"#996600\"\n\
|
||||
accent-blue-light: \"#00000014\"\n\
|
||||
accent-green-light: \"#00660014\"\n\
|
||||
accent-purple-light: \"#66009914\"\n\
|
||||
accent-red-light: \"#CC000014\"\n\
|
||||
accent-yellow-light: \"#99660014\"\n\
|
||||
font-family: \"'SF Mono', 'Menlo', monospace\"\n\
|
||||
font-size-base: 13px\n\
|
||||
editor-font-size: 15\n\
|
||||
editor-line-height: 1.6\n\
|
||||
editor-max-width: 680\n\
|
||||
---\n\
|
||||
\n\
|
||||
# Minimal Theme\n\
|
||||
\n\
|
||||
High contrast, minimal chrome. Monospace typography throughout.\n";
|
||||
/// Build a vault theme note string from a set of CSS variable pairs.
|
||||
///
|
||||
/// Values containing `#`, `'`, `,`, or `(` are YAML-quoted to avoid parse errors.
|
||||
fn build_vault_theme_note(name: &str, description: &str, vars: &[(&str, &str)]) -> String {
|
||||
let mut fm = format!("---\ntype: Theme\nDescription: {description}\n");
|
||||
for (key, value) in vars {
|
||||
if value.contains('#') || value.contains('\'') || value.contains(',') || value.contains('(')
|
||||
{
|
||||
fm.push_str(&format!("{key}: \"{value}\"\n"));
|
||||
} else {
|
||||
fm.push_str(&format!("{key}: {value}\n"));
|
||||
}
|
||||
}
|
||||
fm.push_str("---\n\n");
|
||||
fm.push_str(&format!("# {name} Theme\n\n{description}.\n"));
|
||||
fm
|
||||
}
|
||||
|
||||
/// Apply overrides on top of DEFAULT_VAULT_THEME_VARS, returning a new Vec.
|
||||
fn apply_overrides(
|
||||
overrides: &[(&'static str, &'static str)],
|
||||
) -> Vec<(&'static str, &'static str)> {
|
||||
let mut vars: Vec<(&'static str, &'static str)> = DEFAULT_VAULT_THEME_VARS.to_vec();
|
||||
for &(key, value) in overrides {
|
||||
if let Some(entry) = vars.iter_mut().find(|e| e.0 == key) {
|
||||
entry.1 = value;
|
||||
}
|
||||
}
|
||||
vars
|
||||
}
|
||||
|
||||
/// Generate the Default vault theme note content.
|
||||
pub fn default_vault_theme() -> String {
|
||||
build_vault_theme_note(
|
||||
"Default",
|
||||
"Light theme with warm, paper-like tones",
|
||||
&DEFAULT_VAULT_THEME_VARS,
|
||||
)
|
||||
}
|
||||
|
||||
/// Generate the Dark vault theme note content.
|
||||
pub fn dark_vault_theme() -> String {
|
||||
let vars = apply_overrides(DARK_COLOR_OVERRIDES);
|
||||
build_vault_theme_note("Dark", "Dark variant with deep navy tones", &vars)
|
||||
}
|
||||
|
||||
/// Generate the Minimal vault theme note content.
|
||||
pub fn minimal_vault_theme() -> String {
|
||||
let vars = apply_overrides(MINIMAL_OVERRIDES);
|
||||
build_vault_theme_note("Minimal", "High contrast, minimal chrome", &vars)
|
||||
}
|
||||
|
||||
/// Type definition for the Theme note type.
|
||||
pub const THEME_TYPE_DEFINITION: &str = "---\n\
|
||||
Is A: Type\n\
|
||||
type: Type\n\
|
||||
icon: palette\n\
|
||||
color: purple\n\
|
||||
order: 50\n\
|
||||
|
||||
@@ -258,7 +258,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_vault_theme_content_contains_all_vars() {
|
||||
let content = DEFAULT_VAULT_THEME;
|
||||
let content = default_vault_theme();
|
||||
assert!(content.contains("background:"));
|
||||
assert!(content.contains("primary:"));
|
||||
assert!(content.contains("sidebar:"));
|
||||
|
||||
@@ -31,6 +31,15 @@ pub fn seed_default_themes(vault_path: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Write a vault theme file if it doesn't exist or is empty (corrupt).
|
||||
fn write_if_missing(path: &Path, content: &str) -> Result<bool, String> {
|
||||
let needs_write = !path.exists() || fs::metadata(path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
|
||||
}
|
||||
Ok(needs_write)
|
||||
}
|
||||
|
||||
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
|
||||
/// Per-file idempotent: creates the directory if missing, writes each default
|
||||
/// file only when it doesn't exist or is empty (corrupt). Never overwrites
|
||||
@@ -40,19 +49,18 @@ pub fn seed_vault_themes(vault_path: &str) {
|
||||
if fs::create_dir_all(&theme_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let default_content = default_vault_theme();
|
||||
let dark_content = dark_vault_theme();
|
||||
let minimal_content = minimal_vault_theme();
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
("default.md", &default_content),
|
||||
("dark.md", &dark_content),
|
||||
("minimal.md", &minimal_content),
|
||||
];
|
||||
let mut seeded = false;
|
||||
for (name, content) in defaults {
|
||||
let path = theme_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&path, content);
|
||||
seeded = true;
|
||||
}
|
||||
let wrote = write_if_missing(&theme_dir.join(name), content).unwrap_or(false);
|
||||
seeded = seeded || wrote;
|
||||
}
|
||||
if seeded {
|
||||
log::info!("Seeded theme/ with built-in vault themes");
|
||||
@@ -64,17 +72,17 @@ pub fn seed_vault_themes(vault_path: &str) {
|
||||
pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> {
|
||||
let theme_dir = Path::new(vault_path).join("theme");
|
||||
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
let default_content = default_vault_theme();
|
||||
let dark_content = dark_vault_theme();
|
||||
let minimal_content = minimal_vault_theme();
|
||||
let defaults: &[(&str, &str)] = &[
|
||||
("default.md", DEFAULT_VAULT_THEME),
|
||||
("dark.md", DARK_VAULT_THEME),
|
||||
("minimal.md", MINIMAL_VAULT_THEME),
|
||||
("default.md", &default_content),
|
||||
("dark.md", &dark_content),
|
||||
("minimal.md", &minimal_content),
|
||||
];
|
||||
for (name, content) in defaults {
|
||||
let path = theme_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, content).map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
|
||||
}
|
||||
write_if_missing(&theme_dir.join(name), content)
|
||||
.map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -93,12 +101,7 @@ pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
|
||||
("minimal.json", MINIMAL_THEME),
|
||||
];
|
||||
for (name, content) in json_defaults {
|
||||
let path = themes_dir.join(name);
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, content)
|
||||
.map_err(|e| format!("Failed to write _themes/{name}: {e}"))?;
|
||||
}
|
||||
write_if_missing(&themes_dir.join(name), content)?;
|
||||
}
|
||||
|
||||
// Seed theme/ markdown notes (reuses ensure_vault_themes for consistency)
|
||||
@@ -114,12 +117,7 @@ pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
|
||||
pub fn ensure_theme_type_definition(vault_path: &str) -> Result<(), String> {
|
||||
let type_dir = Path::new(vault_path).join("type");
|
||||
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
|
||||
let path = type_dir.join("theme.md");
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&path, THEME_TYPE_DEFINITION)
|
||||
.map_err(|e| format!("Failed to write type/theme.md: {e}"))?;
|
||||
}
|
||||
write_if_missing(&type_dir.join("theme.md"), THEME_TYPE_DEFINITION)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -162,7 +160,7 @@ mod tests {
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), &default_vault_theme()).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
seed_vault_themes(vp);
|
||||
@@ -181,7 +179,7 @@ mod tests {
|
||||
|
||||
seed_vault_themes(vp);
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
assert!(content.contains("type: Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -190,7 +188,7 @@ mod tests {
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
|
||||
let custom = "---\ntype: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
|
||||
fs::write(theme_dir.join("default.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
@@ -227,7 +225,7 @@ mod tests {
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
assert!(content.contains("type: Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -236,7 +234,7 @@ mod tests {
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
|
||||
let custom = "---\ntype: Theme\nbackground: \"#123456\"\n---\n";
|
||||
fs::write(theme_dir.join("default.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
@@ -265,7 +263,7 @@ mod tests {
|
||||
"restore must create type/theme.md"
|
||||
);
|
||||
let type_content = fs::read_to_string(vault.join("type").join("theme.md")).unwrap();
|
||||
assert!(type_content.contains("Is A: Type"));
|
||||
assert!(type_content.contains("type: Type"));
|
||||
assert!(type_content.contains("icon: palette"));
|
||||
}
|
||||
|
||||
@@ -280,7 +278,7 @@ mod tests {
|
||||
let path = vault.join("type").join("theme.md");
|
||||
assert!(path.exists());
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("Is A: Type"));
|
||||
assert!(content.contains("type: Type"));
|
||||
assert!(content.contains("icon: palette"));
|
||||
}
|
||||
|
||||
@@ -290,7 +288,7 @@ mod tests {
|
||||
let vault = dir.path().join("vault");
|
||||
let type_dir = vault.join("type");
|
||||
fs::create_dir_all(&type_dir).unwrap();
|
||||
let custom = "---\nIs A: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
|
||||
let custom = "---\ntype: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
|
||||
fs::write(type_dir.join("theme.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
@@ -330,7 +328,7 @@ mod tests {
|
||||
fs::create_dir_all(&themes_dir).unwrap();
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
|
||||
fs::write(theme_dir.join("default.md"), &default_vault_theme()).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
restore_default_themes(vp).unwrap();
|
||||
@@ -341,4 +339,54 @@ mod tests {
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Light theme with warm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seeded_default_theme_contains_editor_properties() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(vault.join("theme").join("default.md")).unwrap();
|
||||
|
||||
// Must contain all editor properties from theme.json
|
||||
assert!(
|
||||
content.contains("editor-font-family:"),
|
||||
"missing editor-font-family"
|
||||
);
|
||||
assert!(
|
||||
content.contains("headings-h1-font-size:"),
|
||||
"missing headings-h1-font-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("lists-bullet-size:"),
|
||||
"missing lists-bullet-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("checkboxes-size:"),
|
||||
"missing checkboxes-size"
|
||||
);
|
||||
assert!(
|
||||
content.contains("inline-styles-bold-font-weight:"),
|
||||
"missing inline-styles-bold"
|
||||
);
|
||||
assert!(
|
||||
content.contains("code-blocks-font-family:"),
|
||||
"missing code-blocks-font-family"
|
||||
);
|
||||
assert!(
|
||||
content.contains("blockquote-border-left-width:"),
|
||||
"missing blockquote"
|
||||
);
|
||||
assert!(
|
||||
content.contains("table-border-color:"),
|
||||
"missing table-border-color"
|
||||
);
|
||||
assert!(
|
||||
content.contains("horizontal-rule-thickness:"),
|
||||
"missing horizontal-rule"
|
||||
);
|
||||
assert!(content.contains("colors-text:"), "missing colors-text");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{parse_md_file, scan_vault, VaultEntry};
|
||||
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 5;
|
||||
const CACHE_VERSION: u32 = 6;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
@@ -25,7 +26,30 @@ fn default_cache_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn cache_path(vault: &Path) -> std::path::PathBuf {
|
||||
/// Compute a deterministic hex hash of the vault path for use as cache filename.
|
||||
fn vault_path_hash(vault: &Path) -> String {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
vault.to_string_lossy().as_ref().hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
/// Return the cache directory. Override with `LAPUTA_CACHE_DIR` env var (for tests).
|
||||
fn cache_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("LAPUTA_CACHE_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~"))
|
||||
.join(".laputa")
|
||||
.join("cache")
|
||||
}
|
||||
|
||||
fn cache_path(vault: &Path) -> PathBuf {
|
||||
cache_dir().join(format!("{}.json", vault_path_hash(vault)))
|
||||
}
|
||||
|
||||
/// Legacy cache path inside the vault directory (pre-migration).
|
||||
fn legacy_cache_path(vault: &Path) -> PathBuf {
|
||||
vault.join(".laputa-cache.json")
|
||||
}
|
||||
|
||||
@@ -123,11 +147,18 @@ fn load_cache(vault: &Path) -> Option<VaultCache> {
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
/// Write cache atomically: write to a temp file then rename.
|
||||
fn write_cache(vault: &Path, cache: &VaultCache) {
|
||||
if let Ok(data) = serde_json::to_string(cache) {
|
||||
let _ = fs::write(cache_path(vault), data);
|
||||
let final_path = cache_path(vault);
|
||||
if let Some(parent) = final_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp_path = final_path.with_extension("tmp");
|
||||
if let Ok(data) = serde_json::to_string(cache) {
|
||||
if fs::write(&tmp_path, &data).is_ok() {
|
||||
let _ = fs::rename(&tmp_path, &final_path);
|
||||
}
|
||||
}
|
||||
ensure_cache_excluded(vault);
|
||||
}
|
||||
|
||||
/// Normalize an absolute path to a relative path for comparison with git output.
|
||||
@@ -156,51 +187,67 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Machine-local files that should never be git-tracked in any vault.
|
||||
/// These are either caches with absolute paths or per-machine settings.
|
||||
const UNTRACKED_FILES: &[&str] = &[
|
||||
".laputa-cache.json",
|
||||
".laputa/settings.json",
|
||||
];
|
||||
/// Copy legacy cache data to the new external location atomically.
|
||||
fn copy_legacy_cache_to(legacy: &Path, dest: &Path) {
|
||||
if let Some(parent) = dest.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp_path = dest.with_extension("tmp");
|
||||
if let Ok(data) = fs::read_to_string(legacy) {
|
||||
if fs::write(&tmp_path, &data).is_ok() {
|
||||
let _ = fs::rename(&tmp_path, dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure machine-local files are excluded from git via `.git/info/exclude`
|
||||
/// and un-tracked if they were previously committed (git rm --cached).
|
||||
/// Called on every cache write so existing vaults self-heal automatically.
|
||||
fn ensure_cache_excluded(vault: &Path) {
|
||||
let git_dir = vault.join(".git");
|
||||
if !git_dir.is_dir() {
|
||||
/// Migrate legacy cache from inside the vault to the new external location.
|
||||
/// Also removes the legacy file from git tracking if present.
|
||||
fn migrate_legacy_cache(vault: &Path) {
|
||||
let legacy = legacy_cache_path(vault);
|
||||
if !legacy.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let exclude_path = git_dir.join("info").join("exclude");
|
||||
|
||||
// 1. Add each entry to .git/info/exclude so git ignores it going forward.
|
||||
let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
|
||||
let mut to_add: Vec<&str> = UNTRACKED_FILES
|
||||
.iter()
|
||||
.filter(|e| !existing.lines().any(|line| line.trim() == **e))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if !to_add.is_empty() {
|
||||
to_add.sort();
|
||||
let separator = if existing.ends_with('\n') || existing.is_empty() { "" } else { "\n" };
|
||||
let additions = to_add.join("\n");
|
||||
let _ = fs::write(&exclude_path, format!("{existing}{separator}{additions}\n"));
|
||||
let new_path = cache_path(vault);
|
||||
if !new_path.exists() {
|
||||
copy_legacy_cache_to(&legacy, &new_path);
|
||||
}
|
||||
|
||||
// 2. Un-track each file if git currently tracks it.
|
||||
// `git rm --cached --quiet --ignore-unmatch` exits 0 even if the file isn't tracked.
|
||||
// This fixes existing vaults where these files were committed before this guard.
|
||||
// Remove legacy file from git tracking if present
|
||||
let _ = std::process::Command::new("git")
|
||||
.args(["rm", "--cached", "--quiet", "--ignore-unmatch", "--"])
|
||||
.args(UNTRACKED_FILES)
|
||||
.args([
|
||||
"rm",
|
||||
"--cached",
|
||||
"--quiet",
|
||||
"--ignore-unmatch",
|
||||
".laputa-cache.json",
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
// Delete the legacy file from disk
|
||||
let _ = fs::remove_file(&legacy);
|
||||
}
|
||||
|
||||
/// Remove entries for files that no longer exist on disk and deduplicate
|
||||
/// by case-folded relative path (handles case-insensitive filesystems like macOS APFS).
|
||||
/// Returns `true` if any entries were removed.
|
||||
fn prune_stale_entries(vault: &Path, entries: &mut Vec<VaultEntry>) -> bool {
|
||||
let before = entries.len();
|
||||
// Remove entries whose files no longer exist on disk
|
||||
entries.retain(|e| std::path::Path::new(&e.path).is_file());
|
||||
// Deduplicate by case-folded relative path
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
entries.retain(|e| {
|
||||
let rel = to_relative_path(&e.path, vault).to_lowercase();
|
||||
seen.insert(rel)
|
||||
});
|
||||
entries.len() != before
|
||||
}
|
||||
|
||||
/// Sort entries by modified_at descending and write the cache.
|
||||
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
|
||||
prune_stale_entries(vault, &mut entries);
|
||||
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
write_cache(
|
||||
vault,
|
||||
@@ -215,18 +262,18 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
}
|
||||
|
||||
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
|
||||
/// Always prunes stale entries even when git reports no changes, so that files
|
||||
/// deleted outside git (e.g., via Finder) are removed from the cache on vault open.
|
||||
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
||||
let changed = git_uncommitted_files(vault);
|
||||
if changed.is_empty() {
|
||||
return cache.entries;
|
||||
let mut entries = cache.entries;
|
||||
if !changed.is_empty() {
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault)));
|
||||
entries.extend(parse_files_at(vault, &changed));
|
||||
}
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
let mut entries: Vec<VaultEntry> = cache
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
|
||||
.collect();
|
||||
entries.extend(parse_files_at(vault, &changed));
|
||||
// Always finalize: prune_stale_entries inside finalize_and_cache removes
|
||||
// entries for files deleted outside git (e.g., via Finder or another app).
|
||||
finalize_and_cache(vault, entries, cache.commit_hash)
|
||||
}
|
||||
|
||||
@@ -249,6 +296,14 @@ fn update_different_commit(
|
||||
finalize_and_cache(vault, entries, current_hash)
|
||||
}
|
||||
|
||||
/// Delete the cache file for a vault, forcing a full rescan on the next
|
||||
/// call to `scan_vault_cached`. Used by the `reload_vault` command so that
|
||||
/// explicit user-triggered reloads always read from the filesystem.
|
||||
pub fn invalidate_cache(vault_path: &Path) {
|
||||
let path = cache_path(vault_path);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Scan vault with incremental caching via git.
|
||||
/// Falls back to full scan if cache is missing/corrupt or git is unavailable.
|
||||
pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
@@ -259,6 +314,9 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
));
|
||||
}
|
||||
|
||||
// Migrate legacy in-vault cache to external location on first run
|
||||
migrate_legacy_cache(vault_path);
|
||||
|
||||
let current_hash = match git_head_hash(vault_path) {
|
||||
Some(h) => h,
|
||||
None => return scan_vault(vault_path),
|
||||
@@ -288,8 +346,19 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Serialize all cache tests that mutate the LAPUTA_CACHE_DIR env var.
|
||||
/// `std::env::set_var` is process-global, so parallel tests would race.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Set up a temporary cache directory for test isolation.
|
||||
/// Caller MUST hold `ENV_LOCK` for the duration of the test.
|
||||
fn set_test_cache_dir(dir: &Path) {
|
||||
std::env::set_var("LAPUTA_CACHE_DIR", dir.to_string_lossy().as_ref());
|
||||
}
|
||||
|
||||
fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
let file_path = dir.join(name);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
@@ -299,8 +368,157 @@ mod tests {
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
fn init_git_repo(vault: &Path) {
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Common setup: acquire env lock, create temp cache dir + git-initialised vault.
|
||||
/// Returns (lock_guard, cache_tmpdir, vault_tmpdir) — keep all alive for the test.
|
||||
fn setup_git_vault() -> (std::sync::MutexGuard<'static, ()>, TempDir, TempDir) {
|
||||
let lock = ENV_LOCK.lock().unwrap();
|
||||
let cache_tmp = TempDir::new().unwrap();
|
||||
set_test_cache_dir(cache_tmp.path());
|
||||
let vault_tmp = TempDir::new().unwrap();
|
||||
init_git_repo(vault_tmp.path());
|
||||
(lock, cache_tmp, vault_tmp)
|
||||
}
|
||||
|
||||
fn git_add_commit(vault: &Path, msg: &str) {
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", msg])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_path_is_outside_vault() {
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
let cache_dir = TempDir::new().unwrap();
|
||||
set_test_cache_dir(cache_dir.path());
|
||||
|
||||
let vault = Path::new("/Users/test/MyVault");
|
||||
let path = cache_path(vault);
|
||||
|
||||
// Cache must NOT be inside the vault
|
||||
assert!(
|
||||
!path.starts_with(vault),
|
||||
"cache path must be outside the vault, got: {}",
|
||||
path.display()
|
||||
);
|
||||
// Cache must be under the cache directory
|
||||
assert!(
|
||||
path.starts_with(cache_dir.path()),
|
||||
"cache path must be under cache dir, got: {}",
|
||||
path.display()
|
||||
);
|
||||
// Must end with .json
|
||||
assert_eq!(path.extension().unwrap(), "json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_path_hash_is_deterministic() {
|
||||
let hash1 = vault_path_hash(Path::new("/Users/test/MyVault"));
|
||||
let hash2 = vault_path_hash(Path::new("/Users/test/MyVault"));
|
||||
assert_eq!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_vaults_get_different_hashes() {
|
||||
let hash1 = vault_path_hash(Path::new("/Users/test/Vault1"));
|
||||
let hash2 = vault_path_hash(Path::new("/Users/test/Vault2"));
|
||||
assert_ne!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atomic_write_no_tmp_file_left() {
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
let cache_dir = TempDir::new().unwrap();
|
||||
set_test_cache_dir(cache_dir.path());
|
||||
|
||||
let vault_dir = TempDir::new().unwrap();
|
||||
let vault = vault_dir.path();
|
||||
|
||||
let cache = VaultCache {
|
||||
version: CACHE_VERSION,
|
||||
vault_path: vault.to_string_lossy().to_string(),
|
||||
commit_hash: "abc123".to_string(),
|
||||
entries: vec![],
|
||||
};
|
||||
|
||||
write_cache(vault, &cache);
|
||||
|
||||
// Final file should exist
|
||||
let final_path = cache_path(vault);
|
||||
assert!(final_path.exists(), "cache file must exist after write");
|
||||
|
||||
// Tmp file should NOT exist (renamed away)
|
||||
let tmp_path = final_path.with_extension("tmp");
|
||||
assert!(
|
||||
!tmp_path.exists(),
|
||||
"tmp file must not exist after atomic write"
|
||||
);
|
||||
|
||||
// Content must be valid JSON
|
||||
let data = fs::read_to_string(&final_path).unwrap();
|
||||
let loaded: VaultCache = serde_json::from_str(&data).unwrap();
|
||||
assert_eq!(loaded.commit_hash, "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_cache_migration() {
|
||||
let (_lock, _cache_tmp, vault_dir) = setup_git_vault();
|
||||
let vault = vault_dir.path();
|
||||
|
||||
// Create a legacy cache file inside the vault
|
||||
let legacy = legacy_cache_path(vault);
|
||||
let cache = VaultCache {
|
||||
version: CACHE_VERSION,
|
||||
vault_path: vault.to_string_lossy().to_string(),
|
||||
commit_hash: "old123".to_string(),
|
||||
entries: vec![],
|
||||
};
|
||||
fs::write(&legacy, serde_json::to_string(&cache).unwrap()).unwrap();
|
||||
|
||||
// Run migration
|
||||
migrate_legacy_cache(vault);
|
||||
|
||||
// New cache file should exist with migrated data
|
||||
let new_path = cache_path(vault);
|
||||
assert!(new_path.exists(), "migrated cache must exist");
|
||||
let data = fs::read_to_string(&new_path).unwrap();
|
||||
let loaded: VaultCache = serde_json::from_str(&data).unwrap();
|
||||
assert_eq!(loaded.commit_hash, "old123");
|
||||
|
||||
// Legacy file should be deleted
|
||||
assert!(!legacy.exists(), "legacy cache file must be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_no_git() {
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
let cache_dir = TempDir::new().unwrap();
|
||||
set_test_cache_dir(cache_dir.path());
|
||||
|
||||
// Without git, scan_vault_cached falls back to scan_vault
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "note.md", "# Note\n\nContent here.");
|
||||
@@ -313,43 +531,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_with_git() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init git repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "note.md", "# Note\n\nFirst version.");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// First call: full scan, writes cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(cache_path(vault).exists());
|
||||
|
||||
// Cache must NOT be inside the vault
|
||||
assert!(
|
||||
!cache_path(vault).starts_with(vault),
|
||||
"cache must be outside the vault"
|
||||
);
|
||||
|
||||
// Second call: uses cache (same HEAD)
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
@@ -358,37 +556,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_invalidates_stale_vault_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init git repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "note.md", "# Note\n\nContent.");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Build cache normally
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -423,37 +595,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_incremental_different_commit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init git repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "first.md", "# First\n\nFirst note.");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "first"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "first");
|
||||
|
||||
// Build cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -461,16 +607,7 @@ mod tests {
|
||||
|
||||
// Add a second file and commit
|
||||
create_test_file(vault, "second.md", "# Second\n\nSecond note.");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "second"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "second");
|
||||
|
||||
// Incremental update: cache has old commit, new commit adds second.md
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
@@ -482,37 +619,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_picks_up_modified_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Commit a type note without sidebar label
|
||||
create_test_file(vault, "type/news.md", "---\ntype: Type\n---\n# News\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime the cache (same commit hash)
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -538,36 +650,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_new_file_still_added() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "existing.md", "# Existing\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -586,36 +673,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_new_files_in_new_subdirectory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "existing.md", "# Existing\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -645,4 +707,243 @@ mod tests {
|
||||
assert!(titles.contains(&"Default Theme"));
|
||||
assert!(titles.contains(&"Dark Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_visible_removed_from_type_note() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
// Commit a type note with visible: false
|
||||
create_test_file(
|
||||
vault,
|
||||
"type/topic.md",
|
||||
"---\ntype: Type\nvisible: false\n---\n# Topic\n",
|
||||
);
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime the cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(
|
||||
entries[0].visible,
|
||||
Some(false),
|
||||
"visible must be false initially"
|
||||
);
|
||||
|
||||
// User removes visible field (uncommitted edit)
|
||||
create_test_file(vault, "type/topic.md", "---\ntype: Type\n---\n# Topic\n");
|
||||
|
||||
// Reload — must reflect the removal (visible defaults to None)
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert_eq!(
|
||||
entries2[0].visible, None,
|
||||
"visible must be None after removing the field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deleted_file_removed_from_cache_on_rescan() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "keep.md", "# Keep\n\nStays.");
|
||||
create_test_file(vault, "remove.md", "# Remove\n\nGoes away.");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime cache with both files
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
// Delete file via filesystem (simulates Finder delete)
|
||||
fs::remove_file(vault.join("remove.md")).unwrap();
|
||||
// Also stage the deletion so git status is clean for this file
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "remove.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Rescan — deleted file must be pruned
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1, "deleted file must be pruned on rescan");
|
||||
assert_eq!(entries2[0].title, "Keep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deleted_untracked_file_removed_from_cache() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "tracked.md", "# Tracked\n\nCommitted.");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Create untracked file and prime cache
|
||||
create_test_file(vault, "temp.md", "# Temp\n\nUntracked.");
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
// Delete the untracked file via filesystem
|
||||
fs::remove_file(vault.join("temp.md")).unwrap();
|
||||
|
||||
// Rescan — untracked deleted file must be pruned
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(
|
||||
entries2.len(),
|
||||
1,
|
||||
"deleted untracked file must be pruned on rescan"
|
||||
);
|
||||
assert_eq!(entries2[0].title, "Tracked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_rename_no_duplicates() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "Note.md", "# Note\n\nOriginal case.");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
|
||||
// Simulate case-only rename on case-insensitive FS: delete old, create new
|
||||
fs::remove_file(vault.join("Note.md")).unwrap();
|
||||
create_test_file(vault, "note.md", "# Note\n\nRenamed case.");
|
||||
git_add_commit(vault, "rename");
|
||||
|
||||
// Rescan — must not have duplicates
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(
|
||||
entries2.len(),
|
||||
1,
|
||||
"case-only rename must not create duplicates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalidate_cache_deletes_cache_file() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "note.md", "# Note\n\nContent.");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Build cache
|
||||
let _ = scan_vault_cached(vault).unwrap();
|
||||
assert!(cache_path(vault).exists(), "cache file must exist");
|
||||
|
||||
// Invalidate
|
||||
invalidate_cache(vault);
|
||||
assert!(
|
||||
!cache_path(vault).exists(),
|
||||
"cache file must be deleted after invalidation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalidate_then_scan_forces_full_rescan() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "note.md", "---\nTrashed: false\n---\n# Note\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Build cache — note is not trashed
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(!entries[0].trashed, "note must not be trashed initially");
|
||||
|
||||
// Simulate trashing the note on disk (update frontmatter directly)
|
||||
create_test_file(vault, "note.md", "---\nTrashed: true\n---\n# Note\n");
|
||||
// Stage the change so git sees it
|
||||
git_add_commit(vault, "trash");
|
||||
|
||||
// Without invalidation, scan_vault_cached uses incremental update.
|
||||
// With invalidation, it must do a full rescan from disk.
|
||||
invalidate_cache(vault);
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert!(
|
||||
entries2[0].trashed,
|
||||
"note must be trashed after invalidate + rescan"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: a note with `Archived: Yes` (string, not boolean)
|
||||
/// must be recognized as archived through the full cached vault load path.
|
||||
/// This catches the scenario where a stale cache stores `archived: false`
|
||||
/// and the cache version bump forces a correct re-parse.
|
||||
#[test]
|
||||
fn test_cached_vault_archived_yes_string() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(
|
||||
vault,
|
||||
"archived-note.md",
|
||||
"---\nArchived: Yes\n---\n# Old Note\n",
|
||||
);
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].archived,
|
||||
"'Archived: Yes' must be parsed as true through the cached vault path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: `Trashed: Yes` (string) through full cached path.
|
||||
#[test]
|
||||
fn test_cached_vault_trashed_yes_string() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "trashed-note.md", "---\nTrashed: Yes\n---\n# Gone\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].trashed,
|
||||
"'Trashed: Yes' must be parsed as true through the cached vault path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: stale cache with old version is invalidated and
|
||||
/// re-parses `Archived: Yes` correctly after cache version bump.
|
||||
#[test]
|
||||
fn test_stale_cache_version_forces_rescan_of_archived_yes() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "note.md", "---\nArchived: Yes\n---\n# Note\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let hash = git_head_hash(vault).unwrap();
|
||||
|
||||
// Simulate a stale cache written by old code that parsed Archived: Yes as false
|
||||
let stale_entry = {
|
||||
let mut e = parse_md_file(&vault.join("note.md")).unwrap();
|
||||
e.archived = false; // simulate old parser behavior
|
||||
e
|
||||
};
|
||||
let stale_cache = VaultCache {
|
||||
version: CACHE_VERSION - 1, // old version
|
||||
vault_path: vault.to_string_lossy().to_string(),
|
||||
commit_hash: hash,
|
||||
entries: vec![stale_entry],
|
||||
};
|
||||
write_cache(vault, &stale_cache);
|
||||
|
||||
// Load via cached path — stale version must trigger full rescan
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].archived,
|
||||
"stale cache with old version must be invalidated, re-parsing 'Archived: Yes' as true"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
355
src-tauri/src/vault/config_seed.rs
Normal file
355
src-tauri/src/vault/config_seed.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::getting_started::AGENTS_MD;
|
||||
|
||||
/// Content for `type/config.md` — gives the Config type a sidebar icon and label.
|
||||
const CONFIG_TYPE_DEFINITION: &str = "\
|
||||
---
|
||||
Is A: Type
|
||||
icon: gear-six
|
||||
color: gray
|
||||
order: 90
|
||||
sidebar label: Config
|
||||
---
|
||||
|
||||
# Config
|
||||
|
||||
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
|
||||
";
|
||||
|
||||
/// Minimal root `AGENTS.md` stub that redirects to `config/agents.md`.
|
||||
const AGENTS_MD_STUB: &str = "\
|
||||
# Agent Instructions
|
||||
|
||||
See config/agents.md for vault instructions.
|
||||
";
|
||||
|
||||
/// Seed `config/agents.md` if missing or empty (idempotent, per-file).
|
||||
/// Also seeds `type/config.md` for sidebar visibility.
|
||||
pub fn seed_config_files(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
let config_dir = vault.join("config");
|
||||
if fs::create_dir_all(&config_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let agents_path = config_dir.join("agents.md");
|
||||
let needs_write =
|
||||
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&agents_path, AGENTS_MD);
|
||||
log::info!("Seeded config/agents.md");
|
||||
}
|
||||
|
||||
ensure_config_type_definition(vault_path);
|
||||
}
|
||||
|
||||
/// Ensure `type/config.md` exists (gives Config type a sidebar icon/color).
|
||||
fn ensure_config_type_definition(vault_path: &str) {
|
||||
let type_dir = Path::new(vault_path).join("type");
|
||||
if fs::create_dir_all(&type_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let path = type_dir.join("config.md");
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&path, CONFIG_TYPE_DEFINITION);
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate root `AGENTS.md` → `config/agents.md` for existing vaults.
|
||||
///
|
||||
/// - If root `AGENTS.md` exists and `config/agents.md` does not: move content, write stub.
|
||||
/// - If root `AGENTS.md` exists and `config/agents.md` also exists: just replace root with stub.
|
||||
/// - If root `AGENTS.md` doesn't exist: write the stub anyway (for Codex discoverability).
|
||||
///
|
||||
/// Always idempotent and silent.
|
||||
pub fn migrate_agents_md(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_dir = vault.join("config");
|
||||
let config_agents = config_dir.join("agents.md");
|
||||
|
||||
// Ensure config/ directory exists
|
||||
if fs::create_dir_all(&config_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If root AGENTS.md has real content (not already a stub), migrate it
|
||||
if root_agents.exists() {
|
||||
let content = fs::read_to_string(&root_agents).unwrap_or_default();
|
||||
let is_stub = content.contains("See config/agents.md");
|
||||
|
||||
if !is_stub {
|
||||
// Only move content if config/agents.md doesn't exist yet
|
||||
let config_needs_write = !config_agents.exists()
|
||||
|| fs::metadata(&config_agents).map_or(true, |m| m.len() == 0);
|
||||
if config_needs_write {
|
||||
let _ = fs::write(&config_agents, &content);
|
||||
log::info!("Migrated AGENTS.md content to config/agents.md");
|
||||
}
|
||||
// Replace root with stub
|
||||
let _ = fs::write(&root_agents, AGENTS_MD_STUB);
|
||||
log::info!("Replaced root AGENTS.md with stub pointing to config/agents.md");
|
||||
}
|
||||
} else {
|
||||
// No root AGENTS.md — write stub for Codex discoverability
|
||||
let _ = fs::write(&root_agents, AGENTS_MD_STUB);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair config files: re-create missing `config/agents.md` and `type/config.md`.
|
||||
/// Called by the "Repair Vault" command. Returns a status message.
|
||||
pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Ensure config/ directory
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir)
|
||||
.map_err(|e| format!("Failed to create config directory: {e}"))?;
|
||||
|
||||
let agents_path = config_dir.join("agents.md");
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
|
||||
// Step 1: Migrate root AGENTS.md content → config/agents.md if needed
|
||||
if root_agents.exists() {
|
||||
let root_content = fs::read_to_string(&root_agents).unwrap_or_default();
|
||||
let is_stub = root_content.contains("See config/agents.md");
|
||||
if !is_stub && !root_content.is_empty() {
|
||||
let config_needs_write =
|
||||
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
|
||||
if config_needs_write {
|
||||
fs::write(&agents_path, &root_content)
|
||||
.map_err(|e| format!("Failed to migrate AGENTS.md: {e}"))?;
|
||||
}
|
||||
fs::write(&root_agents, AGENTS_MD_STUB)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Seed config/agents.md with defaults if still missing or empty
|
||||
let needs_write =
|
||||
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&agents_path, AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write config/agents.md: {e}"))?;
|
||||
}
|
||||
|
||||
// Step 3: Ensure type/config.md
|
||||
let type_dir = vault.join("type");
|
||||
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
|
||||
let config_type_path = type_dir.join("config.md");
|
||||
let type_needs_write = !config_type_path.exists()
|
||||
|| fs::metadata(&config_type_path).map_or(true, |m| m.len() == 0);
|
||||
if type_needs_write {
|
||||
fs::write(&config_type_path, CONFIG_TYPE_DEFINITION)
|
||||
.map_err(|e| format!("Failed to write type/config.md: {e}"))?;
|
||||
}
|
||||
|
||||
// Step 4: Ensure root AGENTS.md stub exists
|
||||
let stub_needs_write = !root_agents.exists()
|
||||
|| fs::read_to_string(&root_agents).map_or(true, |c| !c.contains("See config/agents.md"));
|
||||
if stub_needs_write {
|
||||
fs::write(&root_agents, AGENTS_MD_STUB)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?;
|
||||
}
|
||||
|
||||
Ok("Config files repaired".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_dir_and_agents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("config").is_dir());
|
||||
assert!(vault.join("config/agents.md").exists());
|
||||
let content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_type_definition() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("type/config.md").exists());
|
||||
let content = fs::read_to_string(vault.join("type/config.md")).unwrap();
|
||||
assert!(content.contains("Is A: Type"));
|
||||
assert!(content.contains("icon: gear-six"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
// Customize the file
|
||||
let custom = "---\nIs A: Config\n---\n# Custom Agents\nMy custom instructions\n";
|
||||
fs::write(vault.join("config/agents.md"), custom).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("Custom Agents"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_reseeds_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::write(config_dir.join("agents.md"), "").unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_moves_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
// config/agents.md should have the original content
|
||||
let config_content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(config_content.contains("Vault Instructions for AI Agents"));
|
||||
|
||||
// Root AGENTS.md should be a stub
|
||||
let root_content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root_content.contains("See config/agents.md"));
|
||||
assert!(!root_content.contains("## Structure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_preserves_existing_config() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let custom = "# Custom agent instructions\n";
|
||||
fs::write(config_dir.join("agents.md"), custom).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
// config/agents.md should preserve custom content
|
||||
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
|
||||
assert!(content.contains("Custom agent instructions"));
|
||||
|
||||
// Root should be a stub
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_idempotent_on_stub() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), AGENTS_MD_STUB).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
// Stub should remain unchanged
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_writes_stub_when_no_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_creates_all() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(msg, "Config files repaired");
|
||||
|
||||
assert!(vault.join("config/agents.md").exists());
|
||||
assert!(vault.join("type/config.md").exists());
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
|
||||
let agents = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(agents.contains("Vault Instructions for AI Agents"));
|
||||
|
||||
let stub = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(stub.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_preserves_custom_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let custom = "# My custom agent config\nDo not overwrite me\n";
|
||||
fs::write(config_dir.join("agents.md"), custom).unwrap();
|
||||
fs::write(
|
||||
vault.join("AGENTS.md"),
|
||||
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("My custom agent config"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_migrates_root_agents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let original = "# My vault agents instructions\nCustom content here\n";
|
||||
fs::write(vault.join("AGENTS.md"), original).unwrap();
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
// Root should be a stub
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
|
||||
// config/agents.md should have the original content
|
||||
let config = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(config.contains("My vault agents instructions"));
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,10 @@ struct SampleFile {
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Content for the AGENTS.md file written to the vault root.
|
||||
/// Content for config/agents.md — vault instructions for AI agents.
|
||||
/// This file has no YAML frontmatter — it is a convention file for AI agents,
|
||||
/// not a vault note. The vault scanner will still pick it up as a regular entry.
|
||||
const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
|
||||
pub(super) const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
|
||||
|
||||
This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph.
|
||||
|
||||
@@ -51,7 +51,7 @@ YAML frontmatter between `---` delimiters defines metadata:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Project
|
||||
type: Project
|
||||
Status: Active
|
||||
Owner: "[[person/jane-doe]]"
|
||||
Belongs to: "[[quarter/24q1]]"
|
||||
@@ -65,7 +65,7 @@ Related to:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `Is A` | Entity type (usually inferred from folder) |
|
||||
| `type` | Entity type (usually inferred from folder) |
|
||||
| `Status` | Active, Done, Paused, Archived, Dropped |
|
||||
| `Owner` | Person responsible (wikilink) |
|
||||
| `Belongs to` | Parent relationship(s) |
|
||||
@@ -98,7 +98,7 @@ Files in `type/` define entity types and control how they appear in the sidebar:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Type
|
||||
type: Type
|
||||
icon: rocket-launch
|
||||
color: purple
|
||||
order: 1
|
||||
@@ -119,28 +119,32 @@ Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor n
|
||||
const SAMPLE_FILES: &[SampleFile] = &[
|
||||
SampleFile {
|
||||
rel_path: "type/project.md",
|
||||
content: "---\nIs A: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n",
|
||||
content: "---\ntype: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/note.md",
|
||||
content: "---\nIs A: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n",
|
||||
content: "---\ntype: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/person.md",
|
||||
content: "---\nIs A: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n",
|
||||
content: "---\ntype: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/topic.md",
|
||||
content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
|
||||
content: "---\ntype: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/theme.md",
|
||||
content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
|
||||
content: "---\ntype: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/config.md",
|
||||
content: "---\ntype: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "note/welcome-to-laputa.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
type: Note
|
||||
Related to:
|
||||
- "[[note/editor-basics]]"
|
||||
- "[[note/using-properties]]"
|
||||
@@ -175,7 +179,7 @@ Every note is a markdown file with optional YAML frontmatter at the top. Notes l
|
||||
SampleFile {
|
||||
rel_path: "note/editor-basics.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
type: Note
|
||||
Related to: "[[note/welcome-to-laputa]]"
|
||||
---
|
||||
|
||||
@@ -223,7 +227,7 @@ function hello() {
|
||||
SampleFile {
|
||||
rel_path: "note/using-properties.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
type: Note
|
||||
Status: Active
|
||||
Related to:
|
||||
- "[[note/welcome-to-laputa]]"
|
||||
@@ -236,7 +240,7 @@ Every note can have **properties** defined in the YAML frontmatter at the top of
|
||||
|
||||
## Common properties
|
||||
|
||||
- **Is A** — The note's type (Project, Note, Person, etc.)
|
||||
- **type** — The note's type (Project, Note, Person, etc.)
|
||||
- **Status** — Current state: Active, Done, Paused, Archived, Dropped
|
||||
- **Belongs to** — Parent relationship (e.g., a project belongs to a quarter)
|
||||
- **Related to** — Lateral connections to other notes
|
||||
@@ -257,7 +261,7 @@ You can add any custom property. If the value contains `[[wiki-links]]`, Laputa
|
||||
SampleFile {
|
||||
rel_path: "note/wiki-links-and-relationships.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
type: Note
|
||||
Related to:
|
||||
- "[[note/welcome-to-laputa]]"
|
||||
- "[[note/using-properties]]"
|
||||
@@ -296,7 +300,7 @@ Over time, your wiki-links form a rich web of connections. Use the **Referenced
|
||||
SampleFile {
|
||||
rel_path: "project/sample-project.md",
|
||||
content: r#"---
|
||||
Is A: Project
|
||||
type: Project
|
||||
Status: Active
|
||||
Owner: "[[person/sample-collaborator]]"
|
||||
Related to: "[[topic/getting-started]]"
|
||||
@@ -325,7 +329,7 @@ This project is owned by [[person/sample-collaborator]] and relates to [[topic/g
|
||||
SampleFile {
|
||||
rel_path: "person/sample-collaborator.md",
|
||||
content: r#"---
|
||||
Is A: Person
|
||||
type: Person
|
||||
---
|
||||
|
||||
# Sample Collaborator
|
||||
@@ -346,7 +350,7 @@ This person is the owner of [[project/sample-project]]. Check the **Referenced B
|
||||
SampleFile {
|
||||
rel_path: "topic/getting-started.md",
|
||||
content: r#"---
|
||||
Is A: Topic
|
||||
type: Topic
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
@@ -384,9 +388,19 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
fs::create_dir_all(vault_dir)
|
||||
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
|
||||
|
||||
// Write AGENTS.md at the vault root
|
||||
fs::write(vault_dir.join("AGENTS.md"), AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {}", e))?;
|
||||
// Write config/agents.md with vault instructions for AI agents
|
||||
let config_dir = vault_dir.join("config");
|
||||
fs::create_dir_all(&config_dir)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
fs::write(config_dir.join("agents.md"), AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write config/agents.md: {}", e))?;
|
||||
|
||||
// Write root AGENTS.md stub for Codex discoverability
|
||||
fs::write(
|
||||
vault_dir.join("AGENTS.md"),
|
||||
"# Agent Instructions\n\nSee config/agents.md for vault instructions.\n",
|
||||
)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md stub: {}", e))?;
|
||||
|
||||
for sample in SAMPLE_FILES {
|
||||
let file_path = vault_dir.join(sample.rel_path);
|
||||
@@ -414,17 +428,17 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
.map_err(|e| format!("Failed to create theme directory: {e}"))?;
|
||||
fs::write(
|
||||
theme_notes_dir.join("default.md"),
|
||||
crate::theme::DEFAULT_VAULT_THEME,
|
||||
crate::theme::default_vault_theme(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write default vault theme: {e}"))?;
|
||||
fs::write(
|
||||
theme_notes_dir.join("dark.md"),
|
||||
crate::theme::DARK_VAULT_THEME,
|
||||
crate::theme::dark_vault_theme(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write dark vault theme: {e}"))?;
|
||||
fs::write(
|
||||
theme_notes_dir.join("minimal.md"),
|
||||
crate::theme::MINIMAL_VAULT_THEME,
|
||||
crate::theme::minimal_vault_theme(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write minimal vault theme: {e}"))?;
|
||||
|
||||
@@ -462,6 +476,7 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify key files exist
|
||||
assert!(vault_path.join("config/agents.md").exists());
|
||||
assert!(vault_path.join("AGENTS.md").exists());
|
||||
assert!(vault_path.join("note/welcome-to-laputa.md").exists());
|
||||
assert!(vault_path.join("note/editor-basics.md").exists());
|
||||
@@ -476,6 +491,7 @@ mod tests {
|
||||
assert!(vault_path.join("type/note.md").exists());
|
||||
assert!(vault_path.join("type/person.md").exists());
|
||||
assert!(vault_path.join("type/topic.md").exists());
|
||||
assert!(vault_path.join("type/config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -530,57 +546,63 @@ mod tests {
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entries = crate::vault::scan_vault(&vault_path).unwrap();
|
||||
// SAMPLE_FILES + AGENTS.md + 3 vault theme notes (theme/default.md, dark.md, minimal.md)
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3);
|
||||
// SAMPLE_FILES + config/agents.md + AGENTS.md stub + 3 vault theme notes
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 2 + 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_present_after_vault_creation() {
|
||||
fn test_config_agents_md_present_after_vault_creation() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let agents_path = vault_path.join("AGENTS.md");
|
||||
assert!(agents_path.exists(), "AGENTS.md should exist at vault root");
|
||||
let agents_path = vault_path.join("config/agents.md");
|
||||
assert!(
|
||||
agents_path.exists(),
|
||||
"config/agents.md should exist in vault"
|
||||
);
|
||||
|
||||
let content = fs::read_to_string(&agents_path).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
assert!(content.contains("## Structure"));
|
||||
assert!(content.contains("## Frontmatter"));
|
||||
assert!(content.contains("## Wikilinks"));
|
||||
assert!(content.contains("## Type definitions"));
|
||||
assert!(content.contains("## Conventions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_root_agents_md_is_stub_after_vault_creation() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("stub-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let root_path = vault_path.join("AGENTS.md");
|
||||
assert!(root_path.exists(), "Root AGENTS.md stub should exist");
|
||||
|
||||
let content = fs::read_to_string(&root_path).unwrap();
|
||||
assert!(
|
||||
content.contains("Vault Instructions for AI Agents"),
|
||||
"AGENTS.md should contain instructions header"
|
||||
content.contains("See config/agents.md"),
|
||||
"Root AGENTS.md should redirect to config/agents.md"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Structure"),
|
||||
"AGENTS.md should describe vault structure"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Frontmatter"),
|
||||
"AGENTS.md should describe frontmatter"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Wikilinks"),
|
||||
"AGENTS.md should describe wikilinks"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Type definitions"),
|
||||
"AGENTS.md should describe type definitions"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Conventions"),
|
||||
"AGENTS.md should describe conventions"
|
||||
!content.contains("## Structure"),
|
||||
"Root AGENTS.md should not contain full instructions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_parseable_as_vault_entry() {
|
||||
fn test_config_agents_md_parseable_as_vault_entry() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("config/agents.md")).unwrap();
|
||||
assert_eq!(
|
||||
entry.title,
|
||||
"AGENTS.md \u{2014} Vault Instructions for AI Agents"
|
||||
);
|
||||
assert_eq!(entry.is_a.as_deref(), Some("Config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod cache;
|
||||
mod config_seed;
|
||||
mod getting_started;
|
||||
mod image;
|
||||
mod migration;
|
||||
@@ -6,12 +7,13 @@ mod parsing;
|
||||
mod rename;
|
||||
mod trash;
|
||||
|
||||
pub use cache::scan_vault_cached;
|
||||
pub use cache::{invalidate_cache, scan_vault_cached};
|
||||
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{rename_note, RenameResult};
|
||||
pub use trash::{delete_note, purge_trash};
|
||||
pub use rename::{move_note_to_type_folder, rename_note, MoveResult, RenameResult};
|
||||
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
|
||||
|
||||
use parsing::{
|
||||
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
|
||||
@@ -92,7 +94,7 @@ pub struct VaultEntry {
|
||||
/// Intermediate struct to capture YAML frontmatter fields.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct Frontmatter {
|
||||
#[serde(rename = "Is A", alias = "type")]
|
||||
#[serde(rename = "type", alias = "Is A", alias = "is_a")]
|
||||
is_a: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
aliases: Option<StringOrList>,
|
||||
@@ -106,9 +108,19 @@ struct Frontmatter {
|
||||
owner: Option<String>,
|
||||
#[serde(rename = "Cadence")]
|
||||
cadence: Option<String>,
|
||||
#[serde(rename = "Archived")]
|
||||
#[serde(
|
||||
rename = "Archived",
|
||||
alias = "archived",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
archived: Option<bool>,
|
||||
#[serde(rename = "Trashed", alias = "trashed")]
|
||||
#[serde(
|
||||
rename = "Trashed",
|
||||
alias = "trashed",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
trashed: Option<bool>,
|
||||
#[serde(rename = "Trashed at", alias = "trashed_at")]
|
||||
trashed_at: Option<String>,
|
||||
@@ -134,6 +146,56 @@ struct Frontmatter {
|
||||
visible: Option<bool>,
|
||||
}
|
||||
|
||||
/// Custom deserializer for boolean fields that may arrive as strings.
|
||||
/// YAML `Yes`/`No` get converted to JSON strings by gray_matter, so we
|
||||
/// need to accept both actual booleans and their string representations.
|
||||
fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct BoolOrStringVisitor;
|
||||
|
||||
impl<'de> de::Visitor<'de> for BoolOrStringVisitor {
|
||||
type Value = Option<bool>;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a boolean or a string representing a boolean")
|
||||
}
|
||||
|
||||
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
match v.to_lowercase().as_str() {
|
||||
"true" | "yes" | "1" => Ok(Some(true)),
|
||||
"false" | "no" | "0" | "" => Ok(Some(false)),
|
||||
_ => Ok(Some(false)),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
|
||||
Ok(Some(v != 0))
|
||||
}
|
||||
|
||||
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
|
||||
Ok(Some(v != 0))
|
||||
}
|
||||
|
||||
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(BoolOrStringVisitor)
|
||||
}
|
||||
|
||||
/// Handles YAML fields that can be either a single string or a list of strings.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
@@ -277,6 +339,7 @@ fn infer_type_from_folder(folder: &str) -> String {
|
||||
"target" => "Target",
|
||||
"journal" => "Journal",
|
||||
"month" => "Month",
|
||||
"config" => "Config",
|
||||
"essay" => "Essay",
|
||||
"evergreen" => "Evergreen",
|
||||
_ => return title_case_folder(folder),
|
||||
@@ -432,6 +495,15 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read a single file from disk and return a fresh VaultEntry.
|
||||
/// Used after failed optimistic updates to restore the true filesystem state.
|
||||
pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("File does not exist: {}", path.display()));
|
||||
}
|
||||
parse_md_file(path)
|
||||
}
|
||||
|
||||
/// Read the content of a single note file.
|
||||
pub fn get_note_content(path: &Path) -> Result<String, String> {
|
||||
if !path.exists() {
|
||||
@@ -540,6 +612,35 @@ mod tests {
|
||||
parse_md_file(&dir.path().join(name)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_entry_returns_fresh_data() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"note.md",
|
||||
"---\nStatus: Active\n---\n# My Note\n\nOriginal.",
|
||||
);
|
||||
let entry = reload_entry(&dir.path().join("note.md")).unwrap();
|
||||
assert_eq!(entry.title, "My Note");
|
||||
assert_eq!(entry.status, Some("Active".to_string()));
|
||||
|
||||
// Modify on disk and reload — must see the new content
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"note.md",
|
||||
"---\nStatus: Done\n---\n# My Note\n\nUpdated.",
|
||||
);
|
||||
let fresh = reload_entry(&dir.path().join("note.md")).unwrap();
|
||||
assert_eq!(fresh.status, Some("Done".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_entry_nonexistent_file() {
|
||||
let result = reload_entry(std::path::Path::new("/nonexistent/path/note.md"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
const FULL_FM_CONTENT: &str = "---\nIs A: Project\naliases:\n - Laputa\n - Castle in the Sky\nBelongs to:\n - Studio Ghibli\nRelated to:\n - Miyazaki\nStatus: Active\nOwner: Luca\nCadence: Weekly\n---\n# Laputa Project\n\nThis is a project note.\n";
|
||||
|
||||
#[test]
|
||||
@@ -1160,6 +1261,17 @@ References:
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_note_content_deeply_nested_new_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("a/b/c/deep-note.md");
|
||||
let content = "---\ntitle: Deep\n---\n";
|
||||
|
||||
save_note_content(path.to_str().unwrap(), content).unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
// --- sidebar_label tests ---
|
||||
|
||||
#[test]
|
||||
@@ -1371,6 +1483,25 @@ Company: Acme Corp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_lowercase_alias() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\narchived: true\n---\n# Old Quarter\n";
|
||||
let entry = parse_test_entry(&dir, "old-quarter.md", content);
|
||||
assert!(
|
||||
entry.archived,
|
||||
"lowercase 'archived' must be parsed via alias (frontend writes lowercase)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_titlecase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: true\n---\n# Old Quarter\n";
|
||||
let entry = parse_test_entry(&dir, "old-quarter-2.md", content);
|
||||
assert!(entry.archived, "titlecase 'Archived' must also be parsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trashed_false_when_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1380,6 +1511,91 @@ Company: Acme Corp
|
||||
assert!(entry.trashed_at.is_none());
|
||||
}
|
||||
|
||||
// --- archived/trashed string-value tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_yes_titlecase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: Yes\n---\n# Old\n";
|
||||
let entry = parse_test_entry(&dir, "old.md", content);
|
||||
assert!(entry.archived, "'Archived: Yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_yes_lowercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\narchived: yes\n---\n# Old\n";
|
||||
let entry = parse_test_entry(&dir, "old2.md", content);
|
||||
assert!(entry.archived, "'archived: yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_yes_uppercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: YES\n---\n# Old\n";
|
||||
let entry = parse_test_entry(&dir, "old3.md", content);
|
||||
assert!(entry.archived, "'Archived: YES' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_no() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: No\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active2.md", content);
|
||||
assert!(!entry.archived, "'Archived: No' must be parsed as false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_false_string() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: \"false\"\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active3.md", content);
|
||||
assert!(
|
||||
!entry.archived,
|
||||
"'Archived: \"false\"' must be parsed as false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_zero() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: 0\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active4.md", content);
|
||||
assert!(!entry.archived, "'Archived: 0' must be parsed as false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Note\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active5.md", content);
|
||||
assert!(!entry.archived, "absent archived must default to false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_yes_titlecase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: Yes\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone2.md", content);
|
||||
assert!(entry.trashed, "'Trashed: Yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_yes_lowercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntrashed: yes\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone3.md", content);
|
||||
assert!(entry.trashed, "'trashed: yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_no() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: No\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active6.md", content);
|
||||
assert!(!entry.trashed, "'Trashed: No' must be parsed as false");
|
||||
}
|
||||
|
||||
// --- visible field tests ---
|
||||
|
||||
#[test]
|
||||
@@ -1422,6 +1638,32 @@ Company: Acme Corp
|
||||
assert!(entry.properties.get("visible").is_none());
|
||||
}
|
||||
|
||||
// --- round-trip: canonical `type:` field and `Is A:` alias ---
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_type_key_parses_correctly() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Quarter\n---\n# Q1 2026\n";
|
||||
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
|
||||
assert_eq!(entry.is_a, Some("Quarter".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_is_a_alias_still_works() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Quarter\n---\n# Q1 2026\n";
|
||||
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
|
||||
assert_eq!(entry.is_a, Some("Quarter".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_is_a_snake_case_alias_still_works() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nis_a: Quarter\n---\n# Q1 2026\n";
|
||||
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
|
||||
assert_eq!(entry.is_a, Some("Quarter".to_string()));
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -18,12 +18,18 @@ pub(super) fn extract_title(content: &str, filename: &str) -> String {
|
||||
}
|
||||
|
||||
/// Remove YAML frontmatter (triple-dash delimited) from content.
|
||||
/// The closing `---` must appear at the start of a line to avoid matching
|
||||
/// occurrences inside frontmatter values (e.g. `title: foo---bar`).
|
||||
fn strip_frontmatter(content: &str) -> &str {
|
||||
let Some(rest) = content.strip_prefix("---") else {
|
||||
return content;
|
||||
};
|
||||
match rest.find("---") {
|
||||
Some(end) => rest[end + 3..].trim_start(),
|
||||
// Find closing `---` at the start of a line (preceded by newline)
|
||||
match rest.find("\n---") {
|
||||
Some(end) => {
|
||||
let after = end + 4; // skip past "\n---"
|
||||
rest[after..].trim_start()
|
||||
}
|
||||
None => content,
|
||||
}
|
||||
}
|
||||
@@ -384,6 +390,46 @@ mod tests {
|
||||
assert_eq!(count_body_words(content), 6);
|
||||
}
|
||||
|
||||
// --- strip_frontmatter tests ---
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_basic() {
|
||||
let content = "---\ntitle: Test\n---\nBody content.";
|
||||
assert_eq!(strip_frontmatter(content), "Body content.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_no_frontmatter() {
|
||||
let content = "Just plain content.";
|
||||
assert_eq!(strip_frontmatter(content), "Just plain content.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_dashes_in_value() {
|
||||
// The closing --- must be at line start, not inside a value
|
||||
let content = "---\ntitle: foo---bar\nstatus: active\n---\nBody here.";
|
||||
assert_eq!(strip_frontmatter(content), "Body here.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_unclosed() {
|
||||
let content = "---\ntitle: Test\nNo closing fence";
|
||||
assert_eq!(strip_frontmatter(content), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_empty_body() {
|
||||
let content = "---\ntitle: Test\n---\n";
|
||||
assert_eq!(strip_frontmatter(content), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_with_dashes_in_frontmatter_value() {
|
||||
// Regression: strip_frontmatter previously matched --- inside values
|
||||
let content = "---\ntitle: my---note\nstatus: active\n---\n# Title\n\nThree body words.";
|
||||
assert_eq!(count_body_words(content), 3);
|
||||
}
|
||||
|
||||
// --- strip_markdown_chars tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -166,11 +166,194 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
|
||||
.unwrap_or(abs_path)
|
||||
}
|
||||
|
||||
/// Result of a move-to-type-folder operation.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MoveResult {
|
||||
/// New absolute file path after move (same as old if no move happened).
|
||||
pub new_path: String,
|
||||
/// Number of other files updated (wikilink replacements).
|
||||
pub updated_links: usize,
|
||||
/// Whether the file was actually moved (false if already in the right folder).
|
||||
pub moved: bool,
|
||||
}
|
||||
|
||||
/// Convert a type name to a folder slug. All known types are single lowercase words;
|
||||
/// unknown types are slugified (lowercase, non-alphanumeric → hyphen).
|
||||
fn type_to_folder_slug(type_name: &str) -> String {
|
||||
type_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<&str>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
|
||||
fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
|
||||
let dest = dest_dir.join(filename);
|
||||
if !dest.exists() {
|
||||
return dest;
|
||||
}
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let ext = Path::new(filename)
|
||||
.extension()
|
||||
.map(|s| format!(".{}", s.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let mut counter = 2;
|
||||
loop {
|
||||
let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a note to the folder corresponding to its new type, and update wikilinks across the vault.
|
||||
///
|
||||
/// Returns `MoveResult` with `moved: false` if the note is already in the correct folder.
|
||||
/// Creates the target folder if it does not exist.
|
||||
pub fn move_note_to_type_folder(
|
||||
vault_path: &str,
|
||||
note_path: &str,
|
||||
new_type: &str,
|
||||
) -> Result<MoveResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(note_path);
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", note_path));
|
||||
}
|
||||
let new_type = new_type.trim();
|
||||
if new_type.is_empty() {
|
||||
return Err("Type cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let folder_slug = type_to_folder_slug(new_type);
|
||||
|
||||
// Check if already in the correct folder
|
||||
let current_folder = old_file
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if current_folder == folder_slug {
|
||||
return Ok(MoveResult {
|
||||
new_path: note_path.to_string(),
|
||||
updated_links: 0,
|
||||
moved: false,
|
||||
});
|
||||
}
|
||||
|
||||
let filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Create target directory if needed
|
||||
let dest_dir = vault.join(&folder_slug);
|
||||
if !dest_dir.exists() {
|
||||
fs::create_dir_all(&dest_dir)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", dest_dir.display(), e))?;
|
||||
}
|
||||
|
||||
// Determine destination path (handle collisions)
|
||||
let new_file = unique_dest_path(&dest_dir, &filename);
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
// Read content and move
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
|
||||
fs::write(&new_file, &content)
|
||||
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
|
||||
fs::remove_file(old_file)
|
||||
.map_err(|e| format!("Failed to remove old file {}: {}", note_path, e))?;
|
||||
|
||||
// Extract title for wikilink matching
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let old_title = super::extract_title(&content, &old_filename);
|
||||
|
||||
// Update wikilinks across the vault (title stays the same, path changes)
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(note_path, &vault_prefix);
|
||||
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix);
|
||||
|
||||
// Build pattern matching old path stem (e.g. "note/weekly-review")
|
||||
let re = match build_wikilink_pattern(&old_title, old_path_stem) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Ok(MoveResult {
|
||||
new_path: new_path_str,
|
||||
updated_links: 0,
|
||||
moved: true,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Determine the replacement: if path-style wikilinks were used, update to new path.
|
||||
// Title-style wikilinks [[My Note]] stay the same (title hasn't changed).
|
||||
let files = collect_md_files(vault, &new_file);
|
||||
let updated_links = files
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let file_content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if !re.is_match(&file_content) {
|
||||
return false;
|
||||
}
|
||||
// Replace path-based wikilinks (old_path_stem → new_path_stem)
|
||||
// and keep title-based wikilinks as-is.
|
||||
let replaced = re.replace_all(&file_content, |caps: ®ex::Captures| {
|
||||
let full_match = caps.get(0).map(|m| m.as_str()).unwrap_or("");
|
||||
let pipe = caps.get(1);
|
||||
// If the match used the path stem, replace with new path stem
|
||||
if full_match.contains(old_path_stem) {
|
||||
match pipe {
|
||||
Some(p) => format!("[[{}{}]]", new_path_stem, p.as_str()),
|
||||
None => format!("[[{}]]", new_path_stem),
|
||||
}
|
||||
} else {
|
||||
// Title-based link — keep as-is (title hasn't changed)
|
||||
full_match.to_string()
|
||||
}
|
||||
});
|
||||
if replaced != file_content {
|
||||
fs::write(path, replaced.as_ref()).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(MoveResult {
|
||||
new_path: new_path_str,
|
||||
updated_links,
|
||||
moved: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
|
||||
///
|
||||
/// When `old_title_hint` is provided it is used instead of extracting the title from
|
||||
/// the file's H1 heading. This is needed when the caller has already saved updated
|
||||
/// content to disk (e.g. the editor saved a new H1 before triggering the rename)
|
||||
/// so the on-disk H1 already matches `new_title`.
|
||||
pub fn rename_note(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
new_title: &str,
|
||||
old_title_hint: Option<&str>,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
@@ -189,23 +372,34 @@ pub fn rename_note(
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let old_title = super::extract_title(&content, &old_filename);
|
||||
let extracted_title = super::extract_title(&content, &old_filename);
|
||||
let old_title = old_title_hint.unwrap_or(&extracted_title);
|
||||
|
||||
if old_title == new_title {
|
||||
// Check both title and filename: even if the title in content matches,
|
||||
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
|
||||
let expected_filename = format!("{}.md", title_to_slug(new_title));
|
||||
let title_unchanged = old_title == new_title;
|
||||
let filename_matches = old_filename == expected_filename;
|
||||
|
||||
if title_unchanged && filename_matches {
|
||||
return Ok(RenameResult {
|
||||
new_path: old_path.to_string(),
|
||||
updated_files: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Update content (H1 + frontmatter title)
|
||||
let updated_content = update_note_title_in_content(&content, new_title);
|
||||
// Update content only if the title actually changed
|
||||
let updated_content = if title_unchanged {
|
||||
content.clone()
|
||||
} else {
|
||||
update_note_title_in_content(&content, new_title)
|
||||
};
|
||||
|
||||
// Compute new path and write file
|
||||
// Compute new path, handling collisions with numeric suffix
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = parent_dir.join(format!("{}.md", title_to_slug(new_title)));
|
||||
let new_file = unique_dest_path(parent_dir, &expected_filename);
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
fs::write(&new_file, &updated_content)
|
||||
@@ -220,7 +414,7 @@ pub fn rename_note(
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
|
||||
vault_path: vault,
|
||||
old_title: &old_title,
|
||||
old_title,
|
||||
new_title,
|
||||
old_path_stem,
|
||||
exclude_path: &new_file,
|
||||
@@ -278,6 +472,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -314,6 +509,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -338,6 +534,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -352,7 +549,12 @@ mod tests {
|
||||
create_test_file(vault, "note/test.md", "# Test\n");
|
||||
|
||||
let old_path = vault.join("note/test.md");
|
||||
let result = rename_note(vault.to_str().unwrap(), old_path.to_str().unwrap(), " ");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
" ",
|
||||
None,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -372,6 +574,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -395,6 +598,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"New Name",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -417,6 +621,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
new_title,
|
||||
None,
|
||||
)
|
||||
.expect("rename_note should succeed");
|
||||
|
||||
@@ -471,4 +676,424 @@ mod tests {
|
||||
assert!(content.contains("title: Renamed Note"));
|
||||
assert!(content.contains("# Renamed Note"));
|
||||
}
|
||||
|
||||
// --- rename-on-save: filename doesn't match title slug ---
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_filename_mismatch_same_title() {
|
||||
// Simulates: user created "Untitled note", changed H1 to "My New Note",
|
||||
// saved content (H1 now correct), but filename is still "untitled-note.md".
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/untitled-note.md",
|
||||
"---\ntitle: My New Note\ntype: Note\n---\n\n# My New Note\n\nContent.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/untitled-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My New Note",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// File should be renamed to match the title slug
|
||||
assert!(
|
||||
result.new_path.ends_with("my-new-note.md"),
|
||||
"expected my-new-note.md, got {}",
|
||||
result.new_path
|
||||
);
|
||||
assert!(!old_path.exists(), "old file should be removed");
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
|
||||
// Content should be preserved (title was already correct)
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(content.contains("# My New Note"));
|
||||
assert!(content.contains("title: My New Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_collision_appends_suffix() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
// Existing file with the slug we want
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/my-note.md",
|
||||
"---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nExisting.\n",
|
||||
);
|
||||
// File with wrong name that should be renamed to my-note.md
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/untitled-note.md",
|
||||
"---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nNew content.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/untitled-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should get a suffixed name to avoid collision
|
||||
assert!(
|
||||
result.new_path.ends_with("my-note-2.md"),
|
||||
"expected my-note-2.md, got {}",
|
||||
result.new_path
|
||||
);
|
||||
assert!(!old_path.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
// Original file should be untouched
|
||||
assert!(vault.join("note/my-note.md").exists());
|
||||
}
|
||||
|
||||
// --- move_note_to_type_folder tests ---
|
||||
|
||||
#[test]
|
||||
fn test_type_to_folder_slug_known_types() {
|
||||
assert_eq!(type_to_folder_slug("Person"), "person");
|
||||
assert_eq!(type_to_folder_slug("Project"), "project");
|
||||
assert_eq!(type_to_folder_slug("Quarter"), "quarter");
|
||||
assert_eq!(type_to_folder_slug("Note"), "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_to_folder_slug_unknown_types() {
|
||||
assert_eq!(type_to_folder_slug("Key Result"), "key-result");
|
||||
assert_eq!(type_to_folder_slug("My Custom Type"), "my-custom-type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_basic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n\nContent here.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert!(result.new_path.contains("/quarter/weekly-review.md"));
|
||||
assert!(!old_path.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_already_in_correct_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"quarter/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n",
|
||||
);
|
||||
|
||||
let path = vault.join("quarter/weekly-review.md");
|
||||
let result =
|
||||
move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "Quarter")
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.moved);
|
||||
assert_eq!(result.new_path, path.to_str().unwrap());
|
||||
assert_eq!(result.updated_links, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_creates_target_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/my-note.md",
|
||||
"---\ntype: Quarter\n---\n# My Note\n",
|
||||
);
|
||||
|
||||
let dest_dir = vault.join("quarter");
|
||||
assert!(!dest_dir.exists());
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert!(dest_dir.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_filename_collision() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/my-note.md",
|
||||
"---\ntype: Quarter\n---\n# My Note\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"quarter/my-note.md",
|
||||
"---\ntype: Quarter\n---\n# Existing Note\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert!(result.new_path.contains("/quarter/my-note-2.md"));
|
||||
assert!(!old_path.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
// Original file should still exist
|
||||
assert!(vault.join("quarter/my-note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_updates_path_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n\nContent.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"project/my-project.md",
|
||||
"---\ntype: Project\n---\n# My Project\n\nSee [[note/weekly-review]] for details.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert_eq!(result.updated_links, 1);
|
||||
|
||||
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
|
||||
assert!(project_content.contains("[[quarter/weekly-review]]"));
|
||||
assert!(!project_content.contains("[[note/weekly-review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_preserves_title_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/other.md",
|
||||
"---\ntype: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
// Title-based wikilinks should be unchanged
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Weekly Review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_collision_preserves_both_contents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let moving_content =
|
||||
"---\ntype: Quarter\n---\n# Migrate newsletter to Beehiiv\n\nImportant content.\n";
|
||||
let existing_content =
|
||||
"---\ntype: Quarter\n---\n# Feedback for Laputa\n\nCompletely different note.\n";
|
||||
create_test_file(vault, "note/my-note.md", moving_content);
|
||||
create_test_file(vault, "quarter/my-note.md", existing_content);
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
// Must get a unique path, not the existing file's path
|
||||
assert!(result.new_path.contains("/quarter/my-note-2.md"));
|
||||
// Moved note must retain its own content
|
||||
let moved_content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert_eq!(moved_content, moving_content);
|
||||
// Existing note must be untouched
|
||||
let untouched = fs::read_to_string(vault.join("quarter/my-note.md")).unwrap();
|
||||
assert_eq!(untouched, existing_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_with_old_title_hint_updates_wikilinks() {
|
||||
// Simulates H1 sync: content already saved with new H1, but wikilinks still use old title.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
// Note file already has the NEW H1 (simulating savePendingForPath before rename)
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\nIs A: Note\n---\n# Sprint Retrospective\n\nContent.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/other.md",
|
||||
"---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"project/my-project.md",
|
||||
"---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
// Without old_title_hint, rename_note would see H1 = "Sprint Retrospective" == new_title → noop
|
||||
// With old_title_hint = "Weekly Review", it knows to search for [[Weekly Review]] and replace
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
Some("Weekly Review"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 2);
|
||||
assert!(result.new_path.ends_with("sprint-retrospective.md"));
|
||||
assert!(!vault.join("note/weekly-review.md").exists());
|
||||
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
assert!(!other_content.contains("[[Weekly Review]]"));
|
||||
|
||||
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
|
||||
assert!(project_content.contains("[[Sprint Retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_without_hint_backward_compatible() {
|
||||
// Existing behavior: no hint, extracts title from H1
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/other.md",
|
||||
"See [[Weekly Review]] for details.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_hint_same_as_new_title_noop() {
|
||||
// If old_title_hint == new_title, should be a noop
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
Some("My Note"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_empty_type_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/test.md", "# Test\n");
|
||||
|
||||
let path = vault.join("note/test.md");
|
||||
let result = move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_nonexistent_file_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("note/nope.md").to_str().unwrap(),
|
||||
"Quarter",
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_preserves_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let original = "---\ntype: Quarter\ntitle: My Note\n---\n# My Note\n\nImportant content.\n";
|
||||
create_test_file(vault, "note/my-note.md", original);
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert_eq!(content, original);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,78 @@ pub fn delete_note(path: &str) -> Result<String, String> {
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
/// Check whether a file's frontmatter marks it as trashed.
|
||||
/// Returns `true` if `Trashed: true` or `Trashed at` is present.
|
||||
pub fn is_file_trashed(path: &Path) -> bool {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&content);
|
||||
|
||||
// Check for "Trashed at" field — its presence implies trashed
|
||||
if extract_trashed_at_string(&parsed.data).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for "Trashed: true"
|
||||
if let Some(gray_matter::Pod::Hash(ref map)) = parsed.data {
|
||||
if let Some(pod) = map.get("Trashed").or_else(|| map.get("trashed")) {
|
||||
return match pod {
|
||||
gray_matter::Pod::Boolean(b) => *b,
|
||||
gray_matter::Pod::String(s) => {
|
||||
matches!(s.to_ascii_lowercase().as_str(), "yes" | "true")
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Delete multiple note files from disk.
|
||||
/// Returns the list of successfully deleted paths.
|
||||
/// Skips files that don't exist or fail to delete (logs warnings).
|
||||
pub fn batch_delete_notes(paths: &[String]) -> Result<Vec<String>, String> {
|
||||
let mut deleted = Vec::new();
|
||||
for path in paths {
|
||||
let file = Path::new(path.as_str());
|
||||
match try_purge_file(file) {
|
||||
Some(p) => deleted.push(p),
|
||||
None if !file.exists() => {
|
||||
log::warn!("File does not exist, skipping: {}", path);
|
||||
}
|
||||
None => {} // try_purge_file already logged the warning
|
||||
}
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete ALL trashed notes
|
||||
/// (regardless of age). Returns the list of deleted file paths.
|
||||
pub fn empty_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
if !vault.exists() || !vault.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path
|
||||
));
|
||||
}
|
||||
|
||||
let deleted: Vec<String> = WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| is_markdown_file(e.path()))
|
||||
.filter(|e| is_file_trashed(e.path()))
|
||||
.filter_map(|entry| try_purge_file(entry.path()))
|
||||
.collect();
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete those where
|
||||
/// `Trashed at` frontmatter is more than 30 days ago.
|
||||
/// Returns the list of deleted file paths.
|
||||
@@ -155,7 +227,7 @@ mod tests {
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"normal.md",
|
||||
"---\nIs A: Note\n---\n# Normal Note\n",
|
||||
"---\ntype: Note\n---\n# Normal Note\n",
|
||||
);
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
@@ -248,4 +320,144 @@ mod tests {
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("old.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\nTrashed: true\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\nTrashed at: \"2026-01-01\"\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_yes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "trashed.md", "---\nTrashed: Yes\n---\n# Gone\n");
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_normal_note() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
assert!(!is_file_trashed(&dir.path().join("normal.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_archived_not_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"archived.md",
|
||||
"---\nArchived: true\n---\n# Archived\n",
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("archived.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_nonexistent_file() {
|
||||
assert!(!is_file_trashed(Path::new("/nonexistent/path.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_false() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"active.md",
|
||||
"---\nTrashed: false\n---\n# Active\n",
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("active.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_delete_notes_removes_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "a.md", "---\ntitle: A\n---\n# A\n");
|
||||
create_test_file(dir.path(), "b.md", "---\ntitle: B\n---\n# B\n");
|
||||
create_test_file(dir.path(), "keep.md", "---\ntitle: Keep\n---\n# Keep\n");
|
||||
|
||||
let paths = vec![
|
||||
dir.path().join("a.md").to_str().unwrap().to_string(),
|
||||
dir.path().join("b.md").to_str().unwrap().to_string(),
|
||||
];
|
||||
let deleted = batch_delete_notes(&paths).unwrap();
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!dir.path().join("a.md").exists());
|
||||
assert!(!dir.path().join("b.md").exists());
|
||||
assert!(dir.path().join("keep.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_delete_notes_skips_nonexistent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "exists.md", "---\ntitle: X\n---\n# X\n");
|
||||
|
||||
let paths = vec![
|
||||
dir.path().join("exists.md").to_str().unwrap().to_string(),
|
||||
"/nonexistent/path.md".to_string(),
|
||||
];
|
||||
let deleted = batch_delete_notes(&paths).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(!dir.path().join("exists.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_deletes_all_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Recently trashed — should be deleted
|
||||
let recent = chrono::Utc::now()
|
||||
.date_naive()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"recent.md",
|
||||
&format!(
|
||||
"---\nTrashed: true\nTrashed at: \"{}\"\n---\n# Recent\n",
|
||||
recent
|
||||
),
|
||||
);
|
||||
// Old trashed — should be deleted
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"old.md",
|
||||
"---\nTrashed: true\nTrashed at: \"2025-01-01\"\n---\n# Old\n",
|
||||
);
|
||||
// Not trashed — should be kept
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
|
||||
let deleted = empty_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!dir.path().join("recent.md").exists());
|
||||
assert!(!dir.path().join("old.md").exists());
|
||||
assert!(dir.path().join("normal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_empty_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let deleted = empty_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert!(deleted.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_nonexistent_path() {
|
||||
let result = empty_trash("/nonexistent/path/that/does/not/exist");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"endpoints": [
|
||||
"https://refactoringhq.github.io/laputa-app/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFQzlGQ0RFM0E1MTIzNDkKUldSSkkxRTYzdnpKVG13M0Zwd3M1RzErbWhJeEhBQUQyaG90bHBtMkNzMm1MNERZRlpXSGFRMTUK"
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE4NkQ5MDI3REVCRkFGNUMKUldSY3I3L2VKNUJ0cU5JRlRZZlp3NGhnU3ZwbkVKeGVvREpmb2sxRVJndHFpVFZPNlArbEE5R1IK"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,7 @@ describe('App', () => {
|
||||
await waitFor(() => {
|
||||
// "All Notes" should be rendered as the selected nav item
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.getByText('Archive')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
248
src/App.tsx
248
src/App.tsx
@@ -17,7 +17,7 @@ import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { useNoteActions, needsRenameOnSave } from './hooks/useNoteActions'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useViewMode } from './hooks/useViewMode'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
@@ -26,7 +26,6 @@ import { useDialogs } from './hooks/useDialogs'
|
||||
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useGitHistory } from './hooks/useGitHistory'
|
||||
import { useUpdater, restartApp } from './hooks/useUpdater'
|
||||
import { useNavigationHistory } from './hooks/useNavigationHistory'
|
||||
import { useAutoSync } from './hooks/useAutoSync'
|
||||
import { useConflictResolver } from './hooks/useConflictResolver'
|
||||
import { useIndexing } from './hooks/useIndexing'
|
||||
@@ -36,9 +35,13 @@ import { useBuildNumber } from './hooks/useBuildNumber'
|
||||
import { useOnboarding } from './hooks/useOnboarding'
|
||||
import { useThemeManager } from './hooks/useThemeManager'
|
||||
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
|
||||
import { useNavigationGestures } from './hooks/useNavigationGestures'
|
||||
import { useAppNavigation } from './hooks/useAppNavigation'
|
||||
import { useAiActivity } from './hooks/useAiActivity'
|
||||
import { useBulkActions } from './hooks/useBulkActions'
|
||||
import { useDeleteActions } from './hooks/useDeleteActions'
|
||||
import { useLayoutPanels } from './hooks/useLayoutPanels'
|
||||
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
||||
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
@@ -46,45 +49,20 @@ import type { SidebarSelection, VaultEntry } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries } from './utils/noteListHelpers'
|
||||
import { openLocalFile } from './utils/url'
|
||||
import { flushEditorContent } from './utils/autoSave'
|
||||
import './App.css'
|
||||
|
||||
// Type declaration for mock content storage
|
||||
// Type declarations for mock content storage and test overrides
|
||||
declare global {
|
||||
interface Window {
|
||||
__mockContent?: Record<string, string>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map for Playwright test overrides
|
||||
__mockHandlers?: Record<string, (args: any) => any>
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
function useBulkActions(
|
||||
entryActions: { handleArchiveNote: (path: string) => Promise<void>; handleTrashNote: (path: string) => Promise<void> },
|
||||
setToastMessage: (msg: string | null) => void,
|
||||
) {
|
||||
const handleBulkArchive = useCallback(async (paths: string[]) => {
|
||||
for (const path of paths) await entryActions.handleArchiveNote(path)
|
||||
setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} archived`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
const handleBulkTrash = useCallback(async (paths: string[]) => {
|
||||
for (const path of paths) await entryActions.handleTrashNote(path)
|
||||
setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} moved to trash`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
return { handleBulkArchive, handleBulkTrash }
|
||||
}
|
||||
|
||||
function useLayoutPanels() {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(250)
|
||||
const [noteListWidth, setNoteListWidth] = useState(300)
|
||||
const [inspectorWidth, setInspectorWidth] = useState(280)
|
||||
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
|
||||
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))), [])
|
||||
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(200, Math.min(500, w + delta))), [])
|
||||
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta))), [])
|
||||
return { sidebarWidth, noteListWidth, inspectorWidth, inspectorCollapsed, setInspectorCollapsed, handleSidebarResize, handleNoteListResize, handleInspectorResize }
|
||||
}
|
||||
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function App() {
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
@@ -107,14 +85,17 @@ function App() {
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
useVaultConfig(resolvedPath)
|
||||
const { settings, saveSettings } = useSettings()
|
||||
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent)
|
||||
const themeManager = useThemeManager(resolvedPath, vault.entries)
|
||||
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
const indexing = useIndexing(resolvedPath)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
vaultPath: resolvedPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: vault.reloadVault,
|
||||
onSyncUpdated: indexing.triggerIncrementalIndex,
|
||||
onConflict: (files) => {
|
||||
const names = files.map((f) => f.split('/').pop()).join(', ')
|
||||
setToastMessage(`Conflict in ${names} — click to resolve`)
|
||||
@@ -125,8 +106,6 @@ function App() {
|
||||
// Ref bridges for conflict resolution callbacks (notes declared below)
|
||||
const openConflictFileRef = useRef<(relativePath: string) => void>(() => {})
|
||||
|
||||
const indexing = useIndexing(resolvedPath)
|
||||
|
||||
const conflictResolver = useConflictResolver({
|
||||
vaultPath: resolvedPath,
|
||||
onResolved: () => {
|
||||
@@ -170,7 +149,7 @@ function App() {
|
||||
// Read at callback time, so it's always current when user presses Cmd+N.
|
||||
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
|
||||
|
||||
// Keep tab entries in sync with vault entries so banners (trash/archive)
|
||||
// and read-only state react immediately without reopening the note.
|
||||
@@ -189,60 +168,27 @@ function App() {
|
||||
})
|
||||
}, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter)
|
||||
|
||||
const navHistory = useNavigationHistory()
|
||||
|
||||
// Push to navigation history whenever the active tab changes (user-initiated)
|
||||
const navFromHistoryRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (notes.activeTabPath && !navFromHistoryRef.current) {
|
||||
navHistory.push(notes.activeTabPath)
|
||||
}
|
||||
navFromHistoryRef.current = false
|
||||
}, [notes.activeTabPath]) // eslint-disable-line react-hooks/exhaustive-deps -- navHistory.push is stable
|
||||
|
||||
const isEntryExists = useCallback((path: string) => vault.entries.some(e => e.path === path), [vault.entries])
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
const target = navHistory.goBack(isEntryExists)
|
||||
if (target) {
|
||||
navFromHistoryRef.current = true
|
||||
if (notes.tabs.some(t => t.entry.path === target)) {
|
||||
notes.handleSwitchTab(target)
|
||||
} else {
|
||||
const entry = vault.entries.find(e => e.path === target)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}
|
||||
}
|
||||
}, [navHistory, isEntryExists, vault.entries, notes])
|
||||
|
||||
const handleGoForward = useCallback(() => {
|
||||
const target = navHistory.goForward(isEntryExists)
|
||||
if (target) {
|
||||
navFromHistoryRef.current = true
|
||||
if (notes.tabs.some(t => t.entry.path === target)) {
|
||||
notes.handleSwitchTab(target)
|
||||
} else {
|
||||
const entry = vault.entries.find(e => e.path === target)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}
|
||||
}
|
||||
}, [navHistory, isEntryExists, vault.entries, notes])
|
||||
|
||||
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
|
||||
const { handleGoBack, handleGoForward, canGoBack, canGoForward, entriesByPath } = useAppNavigation({
|
||||
entries: vault.entries,
|
||||
tabs: notes.tabs,
|
||||
activeTabPath: notes.activeTabPath,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
onSwitchTab: notes.handleSwitchTab,
|
||||
})
|
||||
|
||||
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
|
||||
const openNoteByPath = useCallback((path: string) => {
|
||||
const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
const entry = entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
|
||||
if (entry) {
|
||||
notes.handleSelectNote(entry)
|
||||
} else {
|
||||
// Entry not yet in vault (just created) — reload then open
|
||||
vault.reloadVault().then(freshEntries => {
|
||||
const fresh = freshEntries.find((e: VaultEntry) => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
const fresh = (freshEntries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
if (fresh) notes.handleSelectNote(fresh)
|
||||
})
|
||||
}
|
||||
}, [vault, notes, resolvedPath])
|
||||
}, [entriesByPath, vault, notes, resolvedPath])
|
||||
|
||||
const aiActivity = useAiActivity({
|
||||
onOpenNote: openNoteByPath,
|
||||
@@ -253,10 +199,18 @@ function App() {
|
||||
onVaultChanged: () => { vault.reloadVault() },
|
||||
})
|
||||
|
||||
// Stable callback for Pulse "open note" — never triggers reloadVault.
|
||||
// Pulse files always exist in the vault; if somehow not found, silently skip.
|
||||
const handlePulseOpenNote = useCallback((relativePath: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}, [entriesByPath, resolvedPath, notes])
|
||||
|
||||
// Agent file operation handlers: auto-open created notes, live-refresh modified notes
|
||||
const handleAgentFileCreated = useCallback((relativePath: string) => {
|
||||
vault.reloadVault().then(freshEntries => {
|
||||
const entry = freshEntries.find((e: VaultEntry) => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
|
||||
const entry = (freshEntries as VaultEntry[]).find(e => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
})
|
||||
}, [vault, notes, resolvedPath])
|
||||
@@ -269,19 +223,51 @@ function App() {
|
||||
}
|
||||
}, [vault, notes, resolvedPath])
|
||||
|
||||
const handleAgentVaultChanged = useCallback(() => {
|
||||
vault.reloadVault()
|
||||
}, [vault])
|
||||
|
||||
const { triggerIncrementalIndex } = indexing
|
||||
const onAfterSave = useCallback(() => {
|
||||
vault.loadModifiedFiles()
|
||||
triggerIncrementalIndex()
|
||||
}, [vault, triggerIncrementalIndex])
|
||||
|
||||
const { notifyThemeSaved } = themeManager
|
||||
const onNotePersisted = useCallback((path: string, content: string) => {
|
||||
vault.clearUnsaved(path)
|
||||
notifyThemeSaved(path, content)
|
||||
}, [vault, notifyThemeSaved])
|
||||
|
||||
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
|
||||
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
|
||||
updateEntry: vault.updateEntry,
|
||||
setTabs: notes.setTabs, setToastMessage, onAfterSave,
|
||||
onNotePersisted: vault.clearUnsaved,
|
||||
onNotePersisted,
|
||||
})
|
||||
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
|
||||
|
||||
// Refs for stable closure in flushBeforeAction (avoids re-creating on every tab/content change)
|
||||
const tabsRef = useRef(notes.tabs)
|
||||
tabsRef.current = notes.tabs // eslint-disable-line react-hooks/refs -- ref sync pattern
|
||||
const unsavedPathsRef = useRef(vault.unsavedPaths)
|
||||
unsavedPathsRef.current = vault.unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern
|
||||
|
||||
/** Auto-save unsaved editor content before a destructive action (trash/archive). */
|
||||
const { clearUnsaved: vaultClearUnsaved } = vault
|
||||
const flushBeforeAction = useCallback(async (path: string) => {
|
||||
try {
|
||||
await flushEditorContent(path, {
|
||||
savePendingForPath,
|
||||
getTabContent: (p) => tabsRef.current.find(t => t.entry.path === p)?.content,
|
||||
isUnsaved: (p) => unsavedPathsRef.current.has(p),
|
||||
onSaved: (p) => { vaultClearUnsaved(p) },
|
||||
})
|
||||
} catch (err) {
|
||||
setToastMessage(`Auto-save failed: ${err}`)
|
||||
throw err
|
||||
}
|
||||
}, [savePendingForPath, vaultClearUnsaved, setToastMessage])
|
||||
|
||||
// Wire conflict file opener now that notes is available
|
||||
useEffect(() => {
|
||||
openConflictFileRef.current = (relativePath: string) => {
|
||||
@@ -292,21 +278,32 @@ function App() {
|
||||
notes.handleSelectNote(entry)
|
||||
dialogs.closeConflictResolver()
|
||||
} else {
|
||||
// Non-note file (e.g. .laputa-cache.json, settings.json) —
|
||||
// Non-note file (e.g. settings.json) —
|
||||
// open with system default app so the user can inspect/edit it
|
||||
openLocalFile(fullPath)
|
||||
}
|
||||
}
|
||||
}, [resolvedPath, vault.entries, notes, dialogs])
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
}, [notes, resolvedPath, vault, savePendingForPath])
|
||||
|
||||
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
|
||||
// and trigger file rename when the title slug doesn't match the filename.
|
||||
const handleSave = useCallback(async () => {
|
||||
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
|
||||
const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path)
|
||||
? { path: activeTab.entry.path, content: activeTab.content }
|
||||
: undefined
|
||||
await handleSaveRaw(fallback)
|
||||
}, [handleSaveRaw, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
|
||||
|
||||
// After saving, check if filename needs to match the current title
|
||||
if (activeTab && needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)) {
|
||||
await handleRenameTab(activeTab.entry.path, activeTab.entry.title)
|
||||
}
|
||||
}, [handleSaveRaw, handleRenameTab, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
|
||||
|
||||
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
|
||||
|
||||
@@ -315,19 +312,17 @@ function App() {
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
onBeforeAction: flushBeforeAction,
|
||||
})
|
||||
|
||||
const handleDeleteNote = useCallback(async (path: string) => {
|
||||
try {
|
||||
if (isTauri()) await invoke('delete_note', { path })
|
||||
else await mockInvoke('delete_note', { path })
|
||||
notes.handleCloseTab(path)
|
||||
vault.removeEntry(path)
|
||||
setToastMessage('Note permanently deleted')
|
||||
} catch (e) {
|
||||
setToastMessage(`Failed to delete note: ${e}`)
|
||||
}
|
||||
}, [notes, vault, setToastMessage])
|
||||
const deleteActions = useDeleteActions({
|
||||
vaultPath: resolvedPath,
|
||||
entries: vault.entries,
|
||||
handleCloseTab: notes.handleCloseTab,
|
||||
removeEntry: vault.removeEntry,
|
||||
setToastMessage,
|
||||
})
|
||||
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
|
||||
|
||||
@@ -336,19 +331,12 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
/** H1→title sync: save pending content then rename file + update wikilinks. */
|
||||
const handleTitleSync = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
}, [notes, resolvedPath, vault, savePendingForPath])
|
||||
|
||||
/** H1→title sync: update VaultEntry.title and tab entry in memory. */
|
||||
const handleTitleSync = useCallback((path: string, newTitle: string) => {
|
||||
vault.updateEntry(path, { title: newTitle })
|
||||
notes.setTabs(prev => prev.map(t =>
|
||||
t.entry.path === path ? { ...t, entry: { ...t.entry, title: newTitle } } : t
|
||||
))
|
||||
}, [vault, notes])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
@@ -393,10 +381,23 @@ function App() {
|
||||
}
|
||||
}, [resolvedPath, vault, themeManager, setToastMessage])
|
||||
|
||||
const handleRepairVault = useCallback(async () => {
|
||||
if (!resolvedPath) return
|
||||
try {
|
||||
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
||||
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
|
||||
await vault.reloadVault()
|
||||
await themeManager.reloadThemes()
|
||||
setToastMessage(msg)
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to repair vault: ${err}`)
|
||||
}
|
||||
}, [resolvedPath, vault, themeManager, setToastMessage])
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
|
||||
entries: vault.entries, allContent: vault.allContent,
|
||||
entries: vault.entries,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
selection,
|
||||
@@ -421,7 +422,7 @@ function App() {
|
||||
onSwitchTab: notes.handleSwitchTab, onReplaceActiveTab: notes.handleReplaceActiveTab,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
onGoBack: handleGoBack, onGoForward: handleGoForward,
|
||||
canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward,
|
||||
canGoBack: canGoBack, canGoForward: canGoForward,
|
||||
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
|
||||
onSwitchTheme: themeManager.switchTheme,
|
||||
onCreateTheme: async () => {
|
||||
@@ -448,6 +449,12 @@ function App() {
|
||||
vaultCount: vaultSwitcher.allVaults.length,
|
||||
mcpStatus,
|
||||
onInstallMcp: installMcp,
|
||||
onEmptyTrash: deleteActions.handleEmptyTrash,
|
||||
trashedCount: deleteActions.trashedCount,
|
||||
onReopenClosedTab: notes.handleReopenClosedTab,
|
||||
onReindexVault: indexing.triggerFullReindex,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
})
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
@@ -509,13 +516,9 @@ function App() {
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={(relativePath) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = vault.entries.find(e => e.path === fullPath || e.path === relativePath)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -540,11 +543,11 @@ function App() {
|
||||
onInspectorResize={layout.handleInspectorResize}
|
||||
inspectorEntry={activeTab?.entry ?? null}
|
||||
inspectorContent={activeTab?.content ?? null}
|
||||
allContent={vault.allContent}
|
||||
gitHistory={gitHistory}
|
||||
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
|
||||
onDeleteProperty={notes.handleDeleteProperty}
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
vaultPath={resolvedPath}
|
||||
@@ -552,7 +555,7 @@ function App() {
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onTrashNote={entryActions.handleTrashNote}
|
||||
onRestoreNote={entryActions.handleRestoreNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
onDeleteNote={deleteActions.handleDeleteNote}
|
||||
onArchiveNote={entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
onRenameTab={handleRenameTab}
|
||||
@@ -561,19 +564,20 @@ function App() {
|
||||
onTitleSync={handleTitleSync}
|
||||
rawToggleRef={rawToggleRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
canGoBack={navHistory.canGoBack}
|
||||
canGoForward={navHistory.canGoForward}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
onGoBack={handleGoBack}
|
||||
onGoForward={handleGoForward}
|
||||
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
|
||||
isDarkTheme={themeManager.isDark}
|
||||
onFileCreated={handleAgentFileCreated}
|
||||
onFileModified={handleAgentFileModified}
|
||||
onVaultChanged={handleAgentVaultChanged}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} onRetryIndexing={indexing.retryIndexing} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
@@ -600,6 +604,16 @@ function App() {
|
||||
onOpenSettings={() => { dialogs.closeGitHubVault(); dialogs.openSettings() }}
|
||||
onGitHubConnected={(token, username) => saveSettings({ ...settings, github_token: token, github_username: username })}
|
||||
/>
|
||||
{deleteActions.confirmDelete && (
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title={deleteActions.confirmDelete.title}
|
||||
message={deleteActions.confirmDelete.message}
|
||||
confirmLabel={deleteActions.confirmDelete.confirmLabel}
|
||||
onConfirm={deleteActions.confirmDelete.onConfirm}
|
||||
onCancel={() => deleteActions.setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ import { MarkdownContent } from './MarkdownContent'
|
||||
|
||||
interface AIChatPanelProps {
|
||||
entry: VaultEntry | null
|
||||
allContent: Record<string, string>
|
||||
entries?: VaultEntry[]
|
||||
onClose: () => void
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}
|
||||
|
||||
function TypingIndicator() {
|
||||
@@ -99,10 +99,10 @@ function ContextSearchDropdown({
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
|
||||
function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<MarkdownContent content={msg.content} />
|
||||
<MarkdownContent content={msg.content} onWikilinkClick={onNavigateWikilink} />
|
||||
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
|
||||
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
|
||||
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
|
||||
@@ -121,10 +121,10 @@ function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => v
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingContent({ content }: { content: string }) {
|
||||
function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<MarkdownContent content={content} />
|
||||
<MarkdownContent content={content} onWikilinkClick={onNavigateWikilink} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -176,17 +176,17 @@ function useContextNotes(entry: VaultEntry | null) {
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) {
|
||||
export function AIChatPanel({ entry, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const ctx = useContextNotes(entry)
|
||||
const chat = useAIChat(allContent, ctx.contextNotes)
|
||||
const chat = useAIChat(ctx.contextNotes)
|
||||
|
||||
const contextInfo = useMemo(
|
||||
() => buildSystemPrompt(ctx.contextNotes, allContent),
|
||||
[ctx.contextNotes, allContent],
|
||||
() => buildSystemPrompt(ctx.contextNotes),
|
||||
[ctx.contextNotes],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -213,7 +213,7 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
|
||||
<MessageList
|
||||
messages={chat.messages} isStreaming={chat.isStreaming}
|
||||
streamingContent={chat.streamingContent} onRetry={chat.retryMessage}
|
||||
messagesEndRef={messagesEndRef}
|
||||
messagesEndRef={messagesEndRef} onNavigateWikilink={onNavigateWikilink}
|
||||
/>
|
||||
|
||||
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
|
||||
@@ -284,10 +284,11 @@ function ContextBar({
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
messages, isStreaming, streamingContent, onRetry, messagesEndRef,
|
||||
messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink,
|
||||
}: {
|
||||
messages: ChatMessage[]; isStreaming: boolean; streamingContent: string
|
||||
onRetry: (idx: number) => void; messagesEndRef: React.RefObject<HTMLDivElement | null>
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
@@ -302,10 +303,10 @@ function MessageList({
|
||||
<div key={msg.id} style={{ marginBottom: 12 }}>
|
||||
{msg.role === 'user'
|
||||
? <UserBubble content={msg.content} />
|
||||
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} />}
|
||||
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />}
|
||||
</div>
|
||||
))}
|
||||
{isStreaming && streamingContent && <StreamingContent content={streamingContent} />}
|
||||
{isStreaming && streamingContent && <StreamingContent content={streamingContent} onNavigateWikilink={onNavigateWikilink} />}
|
||||
{isStreaming && !streamingContent && <TypingIndicator />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface AiMessageProps {
|
||||
response?: string
|
||||
isStreaming?: boolean
|
||||
onOpenNote?: (path: string) => void
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}
|
||||
|
||||
function ReferencePill({ reference, onClick }: {
|
||||
@@ -147,10 +148,10 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ResponseBlock({ text }: { text: string }) {
|
||||
function ResponseBlock({ text, onNavigateWikilink }: { text: string; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<MarkdownContent content={text} />
|
||||
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
|
||||
<button
|
||||
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
style={{ fontSize: 11, marginTop: 4 }}
|
||||
@@ -175,7 +176,7 @@ function StreamingIndicator() {
|
||||
)
|
||||
}
|
||||
|
||||
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
|
||||
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
|
||||
// Manual override: null = follow auto behavior, true/false = user forced
|
||||
const [userOverride, setUserOverride] = useState(false)
|
||||
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
|
||||
@@ -212,7 +213,7 @@ export function AiMessage({ userMessage, references, reasoning, reasoningDone, a
|
||||
onToggleExpand={toggleAction}
|
||||
/>
|
||||
)}
|
||||
{response && <ResponseBlock text={response} />}
|
||||
{response && <ResponseBlock text={response} onNavigateWikilink={onNavigateWikilink} />}
|
||||
{isStreaming && !response && <StreamingIndicator />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,10 +4,12 @@ import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
useAiAgent: () => ({
|
||||
messages: [],
|
||||
status: 'idle',
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
}),
|
||||
@@ -45,6 +47,11 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
})
|
||||
|
||||
describe('AiPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockMessages = []
|
||||
mockStatus = 'idle'
|
||||
})
|
||||
|
||||
it('renders panel with AI Chat header', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('AI Chat')).toBeTruthy()
|
||||
@@ -74,7 +81,7 @@ describe('AiPanel', () => {
|
||||
it('renders contextual empty state when active entry is provided', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy()
|
||||
})
|
||||
@@ -82,7 +89,7 @@ describe('AiPanel', () => {
|
||||
it('shows context bar with active entry title', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
expect(screen.getByTestId('context-bar')).toBeTruthy()
|
||||
expect(screen.getByText('My Note')).toBeTruthy()
|
||||
@@ -95,8 +102,7 @@ describe('AiPanel', () => {
|
||||
<AiPanel
|
||||
onClose={vi.fn()} vaultPath="/tmp/vault"
|
||||
activeEntry={entry} entries={[entry, linked]}
|
||||
allContent={{}}
|
||||
/>
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('+ 1 linked')).toBeTruthy()
|
||||
})
|
||||
@@ -122,7 +128,7 @@ describe('AiPanel', () => {
|
||||
it('shows contextual placeholder when active entry exists', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask about this note...')
|
||||
@@ -162,4 +168,41 @@ describe('AiPanel', () => {
|
||||
fireEvent.keyDown(panel, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clicking a wikilink in AI response calls onOpenNote with the target', () => {
|
||||
mockMessages = [{
|
||||
userMessage: 'Tell me about notes',
|
||||
actions: [],
|
||||
response: 'Check out [[Build Laputa App]] for details.',
|
||||
id: 'msg-1',
|
||||
}]
|
||||
const onOpenNote = vi.fn()
|
||||
const { container } = render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')
|
||||
expect(wikilink).toBeTruthy()
|
||||
expect(wikilink!.textContent).toBe('Build Laputa App')
|
||||
fireEvent.click(wikilink!)
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Build Laputa App')
|
||||
})
|
||||
|
||||
it('renders wikilinks with special characters and clicking works', () => {
|
||||
mockMessages = [{
|
||||
userMessage: 'Tell me about meetings',
|
||||
actions: [],
|
||||
response: 'See [[Meeting — 2024/01/15]] and [[Pasta Carbonara]].',
|
||||
id: 'msg-2',
|
||||
}]
|
||||
const onOpenNote = vi.fn()
|
||||
const { container } = render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
fireEvent.click(wikilinks[0])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Meeting — 2024/01/15')
|
||||
fireEvent.click(wikilinks[1])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,10 +13,12 @@ interface AiPanelProps {
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
vaultPath: string
|
||||
activeEntry?: VaultEntry | null
|
||||
/** Direct content of the active note from the editor tab. */
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
@@ -89,8 +91,8 @@ function EmptyState({ hasContext }: { hasContext: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
|
||||
function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; onNavigateWikilink?: (target: string) => void; hasContext: boolean
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -102,14 +104,14 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
|
||||
{messages.map((msg, i) => (
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} onNavigateWikilink={onNavigateWikilink} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -121,22 +123,23 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !allContent || !entries) return undefined
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
allContent,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: pendingRefs.length > 0 ? pendingRefs : undefined,
|
||||
})
|
||||
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
}, [activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
}), [onFileCreated, onFileModified])
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
@@ -167,6 +170,10 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
setPendingRefs(references)
|
||||
@@ -198,6 +205,7 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
messages={agent.messages}
|
||||
isActive={isActive}
|
||||
onOpenNote={onOpenNote}
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<div
|
||||
|
||||
50
src/components/BulkActionBar.test.tsx
Normal file
50
src/components/BulkActionBar.test.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
|
||||
describe('BulkActionBar', () => {
|
||||
const defaultProps = {
|
||||
count: 3,
|
||||
onArchive: vi.fn(),
|
||||
onTrash: vi.fn(),
|
||||
onRestore: vi.fn(),
|
||||
onDeletePermanently: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
isTrashView: false,
|
||||
}
|
||||
|
||||
it('shows Archive and Trash buttons in normal view', () => {
|
||||
render(<BulkActionBar {...defaultProps} />)
|
||||
expect(screen.getByTestId('bulk-archive-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-trash-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-restore-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-delete-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Restore and Delete permanently in trash view', () => {
|
||||
render(<BulkActionBar {...defaultProps} isTrashView={true} />)
|
||||
expect(screen.getByTestId('bulk-restore-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bulk-delete-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-archive-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('bulk-trash-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRestore when Restore button clicked in trash view', () => {
|
||||
const onRestore = vi.fn()
|
||||
render(<BulkActionBar {...defaultProps} isTrashView={true} onRestore={onRestore} />)
|
||||
fireEvent.click(screen.getByTestId('bulk-restore-btn'))
|
||||
expect(onRestore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onDeletePermanently when Delete button clicked in trash view', () => {
|
||||
const onDeletePermanently = vi.fn()
|
||||
render(<BulkActionBar {...defaultProps} isTrashView={true} onDeletePermanently={onDeletePermanently} />)
|
||||
fireEvent.click(screen.getByTestId('bulk-delete-btn'))
|
||||
expect(onDeletePermanently).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows selected count', () => {
|
||||
render(<BulkActionBar {...defaultProps} count={5} />)
|
||||
expect(screen.getByText('5 selected')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,20 @@
|
||||
import { memo } from 'react'
|
||||
import { Archive, Trash, X } from '@phosphor-icons/react'
|
||||
import { Archive, ArrowCounterClockwise, Trash, X } from '@phosphor-icons/react'
|
||||
|
||||
interface BulkActionBarProps {
|
||||
count: number
|
||||
isTrashView: boolean
|
||||
onArchive: () => void
|
||||
onTrash: () => void
|
||||
onRestore: () => void
|
||||
onDeletePermanently: () => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
function BulkActionBarInner({ count, onArchive, onTrash, onClear }: BulkActionBarProps) {
|
||||
const actionBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 } as const
|
||||
const destructiveBtnStyle = { padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 } as const
|
||||
|
||||
function BulkActionBarInner({ count, isTrashView, onArchive, onTrash, onRestore, onDeletePermanently, onClear }: BulkActionBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
@@ -24,26 +30,53 @@ function BulkActionBarInner({ count, onArchive, onTrash, onClear }: BulkActionBa
|
||||
{count} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 10px', borderRadius: 6, background: 'rgba(255,255,255,0.12)', color: 'inherit', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={onArchive}
|
||||
title="Archive selected notes"
|
||||
data-testid="bulk-archive-btn"
|
||||
>
|
||||
<Archive size={14} />
|
||||
Archive
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none cursor-pointer"
|
||||
style={{ padding: '5px 10px', borderRadius: 6, background: 'rgba(224,62,62,0.2)', color: 'var(--destructive)', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={onTrash}
|
||||
title="Move selected notes to trash"
|
||||
data-testid="bulk-trash-btn"
|
||||
>
|
||||
<Trash size={14} />
|
||||
Trash
|
||||
</button>
|
||||
{isTrashView ? (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
|
||||
style={actionBtnStyle}
|
||||
onClick={onRestore}
|
||||
title="Restore selected notes"
|
||||
data-testid="bulk-restore-btn"
|
||||
>
|
||||
<ArrowCounterClockwise size={14} />
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none cursor-pointer"
|
||||
style={destructiveBtnStyle}
|
||||
onClick={onDeletePermanently}
|
||||
title="Permanently delete selected notes"
|
||||
data-testid="bulk-delete-btn"
|
||||
>
|
||||
<Trash size={14} />
|
||||
Delete permanently
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none bg-transparent cursor-pointer"
|
||||
style={actionBtnStyle}
|
||||
onClick={onArchive}
|
||||
title="Archive selected notes"
|
||||
data-testid="bulk-archive-btn"
|
||||
>
|
||||
<Archive size={14} />
|
||||
Archive
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 border-none cursor-pointer"
|
||||
style={destructiveBtnStyle}
|
||||
onClick={onTrash}
|
||||
title="Move selected notes to trash"
|
||||
data-testid="bulk-trash-btn"
|
||||
>
|
||||
<Trash size={14} />
|
||||
Trash
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center border-none bg-transparent cursor-pointer"
|
||||
style={{ padding: '5px 6px', color: 'rgba(255,255,255,0.5)' }}
|
||||
|
||||
81
src/components/ConfirmDeleteDialog.test.tsx
Normal file
81
src/components/ConfirmDeleteDialog.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
|
||||
|
||||
describe('ConfirmDeleteDialog', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders with title and message', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Delete permanently?')).toBeInTheDocument()
|
||||
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm when delete button clicked', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-btn'))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button clicked', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not render when open is false', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={false}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('Delete permanently?')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses custom confirm label when provided', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Empty Trash?"
|
||||
message="Delete all notes?"
|
||||
confirmLabel="Empty Trash"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Empty Trash')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
49
src/components/ConfirmDeleteDialog.tsx
Normal file
49
src/components/ConfirmDeleteDialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { memo } from 'react'
|
||||
import { Trash } from '@phosphor-icons/react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ConfirmDeleteDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Delete permanently',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDeleteDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
|
||||
<DialogContent showCloseButton={false} data-testid="confirm-delete-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash size={18} className="text-destructive" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{message}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-btn">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
@@ -130,7 +130,43 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips aliases and relationship keys', () => {
|
||||
it('renders capitalized Owner with plain text value in Properties panel', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Owner: 'Luca' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument()
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Owner with wikilink value from Properties panel', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Owner: '[[person/luca]]' }}
|
||||
/>
|
||||
)
|
||||
// Owner with wikilink goes to RelationshipsPanel, not Properties
|
||||
expect(screen.queryByText('Owner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders notion_id as a visible property', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ notion_id: 'abc-123-def' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('notion_id')).toBeInTheDocument()
|
||||
expect(screen.getByText('abc-123-def')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips aliases and fields with wikilink values', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -138,12 +174,37 @@ describe('DynamicPropertiesPanel', () => {
|
||||
frontmatter={{ aliases: ['AL'], 'Belongs to': '[[Something]]', cadence: 'Monthly' }}
|
||||
/>
|
||||
)
|
||||
// aliases and "Belongs to" should be skipped
|
||||
// aliases skipped (in SKIP_KEYS); 'Belongs to' skipped (has wikilinks)
|
||||
expect(screen.queryByText('aliases')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Belongs to')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('cadence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows former relationship key with plain text value in Properties', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ 'Belongs to': 'some-team', cadence: 'Monthly' }}
|
||||
/>
|
||||
)
|
||||
// 'Belongs to' has a plain text value, not a wikilink — should render as property
|
||||
expect(screen.getByText('Belongs to')).toBeInTheDocument()
|
||||
expect(screen.getByText('some-team')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides custom field with wikilink value from Properties', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Mentor: '[[person/luca]]' }}
|
||||
/>
|
||||
)
|
||||
// Mentor contains a wikilink → shown in Relationships, not Properties
|
||||
expect(screen.queryByText('Mentor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips is_a, Is A, and type keys (shown via TypeRow instead)', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
|
||||
@@ -27,12 +27,6 @@ import { TagsDropdown } from './TagsDropdown'
|
||||
import { getTagStyle } from '../utils/tagStyles'
|
||||
import { ColorEditableValue } from './ColorInput'
|
||||
|
||||
// Keys that are relationships (contain wikilinks)
|
||||
export const RELATIONSHIP_KEYS = new Set([
|
||||
'Belongs to', 'Related to', 'Events', 'Has Data', 'Owner',
|
||||
'Advances', 'Parent', 'Children', 'Has', 'Notes',
|
||||
])
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||
export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value)
|
||||
@@ -658,14 +652,13 @@ function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: n
|
||||
}
|
||||
|
||||
export function DynamicPropertiesPanel({
|
||||
entry, content, frontmatter, entries, allContent,
|
||||
entry, content, frontmatter, entries,
|
||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
content: string | null
|
||||
frontmatter: ParsedFrontmatter
|
||||
entries?: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
@@ -675,7 +668,7 @@ export function DynamicPropertiesPanel({
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
|
||||
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
||||
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty })
|
||||
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
|
||||
|
||||
const wordCount = countWords(content ?? '')
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Drag-over state: subtle border highlight */
|
||||
|
||||
@@ -13,6 +13,8 @@ const mockEditor = vi.hoisted(() => ({
|
||||
prosemirrorView: {} as Record<string, unknown>,
|
||||
blocksToHTMLLossy: vi.fn(() => ''),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
focus: vi.fn(),
|
||||
setTextCursorPosition: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock BlockNote components
|
||||
@@ -105,7 +107,6 @@ const defaultProps = {
|
||||
onInspectorResize: vi.fn(),
|
||||
inspectorEntry: null as VaultEntry | null,
|
||||
inspectorContent: null as string | null,
|
||||
allContent: {} as Record<string, string>,
|
||||
gitHistory: [],
|
||||
onCreateNote: vi.fn(),
|
||||
}
|
||||
@@ -359,6 +360,68 @@ describe('Editor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('click empty editor space', () => {
|
||||
it('focuses editor at end of last block when clicking empty space below content', () => {
|
||||
mockEditor.focus.mockClear()
|
||||
mockEditor.setTextCursorPosition.mockClear()
|
||||
|
||||
render(
|
||||
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
|
||||
)
|
||||
|
||||
const container = document.querySelector('.editor__blocknote-container')
|
||||
expect(container).toBeTruthy()
|
||||
|
||||
// Click directly on the container (simulates clicking empty space below content)
|
||||
fireEvent.click(container!)
|
||||
|
||||
expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('1', 'end')
|
||||
expect(mockEditor.focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not interfere with clicks on contenteditable elements', () => {
|
||||
mockEditor.focus.mockClear()
|
||||
mockEditor.setTextCursorPosition.mockClear()
|
||||
|
||||
render(
|
||||
<Editor {...defaultProps} tabs={[mockTab]} activeTabPath={mockEntry.path} />
|
||||
)
|
||||
|
||||
// Simulate clicking on a contenteditable child (which ProseMirror would handle)
|
||||
const container = document.querySelector('.editor__blocknote-container')!
|
||||
const editableDiv = document.createElement('div')
|
||||
editableDiv.setAttribute('contenteditable', 'true')
|
||||
container.appendChild(editableDiv)
|
||||
|
||||
fireEvent.click(editableDiv)
|
||||
|
||||
expect(mockEditor.setTextCursorPosition).not.toHaveBeenCalled()
|
||||
// Clean up
|
||||
container.removeChild(editableDiv)
|
||||
})
|
||||
|
||||
it('does not focus editor when note is not editable (trashed)', () => {
|
||||
mockEditor.focus.mockClear()
|
||||
mockEditor.setTextCursorPosition.mockClear()
|
||||
|
||||
const trashedEntry: VaultEntry = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[{ entry: trashedEntry, content: mockContent }]}
|
||||
activeTabPath={trashedEntry.path}
|
||||
/>
|
||||
)
|
||||
|
||||
const container = document.querySelector('.editor__blocknote-container')
|
||||
expect(container).toBeTruthy()
|
||||
fireEvent.click(container!)
|
||||
|
||||
expect(mockEditor.setTextCursorPosition).not.toHaveBeenCalled()
|
||||
expect(mockEditor.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('archived note behavior', () => {
|
||||
it('shows archive banner immediately when entry changes to archived (reactive)', () => {
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -41,11 +41,11 @@ interface EditorProps {
|
||||
onInspectorResize: (delta: number) => void
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
allContent: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
vaultPath?: string
|
||||
@@ -73,6 +73,7 @@ interface EditorProps {
|
||||
diffToggleRef?: React.MutableRefObject<() => void>
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
@@ -119,8 +120,8 @@ export const Editor = memo(function Editor({
|
||||
tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink,
|
||||
onLoadDiff, onLoadDiffAtCommit, getNoteStatus, onCreateNote,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
|
||||
inspectorEntry, inspectorContent, allContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
@@ -131,6 +132,7 @@ export const Editor = memo(function Editor({
|
||||
diffToggleRef,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: EditorProps) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
@@ -148,9 +150,24 @@ export const Editor = memo(function Editor({
|
||||
onTitleSync: onTitleSync ?? (() => {}),
|
||||
})
|
||||
|
||||
// Ref updated by RawEditorView on every keystroke — used to flush
|
||||
// debounced content synchronously before leaving raw mode.
|
||||
const rawLatestContentRef = useRef<string | null>(null)
|
||||
|
||||
const handleBeforeRawEnd = useCallback(() => {
|
||||
if (rawLatestContentRef.current != null && activeTabPath) {
|
||||
onContentChange?.(activeTabPath, rawLatestContentRef.current)
|
||||
}
|
||||
rawLatestContentRef.current = null
|
||||
}, [activeTabPath, onContentChange])
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({
|
||||
activeTabPath, onBeforeRawEnd: handleBeforeRawEnd,
|
||||
})
|
||||
|
||||
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
|
||||
tabs, activeTabPath, editor, onContentChange,
|
||||
onH1Change: onH1Changed, syncActiveRef,
|
||||
onH1Change: onH1Changed, syncActiveRef, rawMode,
|
||||
})
|
||||
useEditorFocus(editor, editorMountedRef)
|
||||
|
||||
@@ -164,8 +181,6 @@ export const Editor = memo(function Editor({
|
||||
activeTabPath, onLoadDiff, onLoadDiffAtCommit,
|
||||
})
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({ activeTabPath })
|
||||
|
||||
const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
|
||||
})
|
||||
@@ -222,6 +237,7 @@ export const Editor = memo(function Editor({
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
isDarkTheme={isDarkTheme}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
/>
|
||||
}
|
||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
@@ -232,7 +248,6 @@ export const Editor = memo(function Editor({
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
gitHistory={gitHistory}
|
||||
vaultPath={vaultPath ?? ''}
|
||||
openTabs={tabs.map(t => t.entry)}
|
||||
@@ -245,9 +260,11 @@ export const Editor = memo(function Editor({
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onOpenNote={onNavigateWikilink}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type React from 'react'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { DiffView } from './DiffView'
|
||||
@@ -41,6 +42,8 @@ interface EditorContentProps {
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
isDarkTheme?: boolean
|
||||
/** Ref updated by RawEditorView on every keystroke with the latest doc. */
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
}
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -72,7 +75,7 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
|
||||
}
|
||||
|
||||
function RawModeEditorSection({
|
||||
rawMode, activeTab, entries, onContentChange, onSave, isDark,
|
||||
rawMode, activeTab, entries, onContentChange, onSave, isDark, latestContentRef,
|
||||
}: {
|
||||
rawMode: boolean
|
||||
activeTab: Tab | null
|
||||
@@ -80,6 +83,7 @@ function RawModeEditorSection({
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
isDark?: boolean
|
||||
latestContentRef?: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
if (!rawMode || !activeTab) return null
|
||||
return (
|
||||
@@ -91,6 +95,7 @@ function RawModeEditorSection({
|
||||
onContentChange={onContentChange ?? (() => {})}
|
||||
onSave={onSave ?? (() => {})}
|
||||
isDark={isDark}
|
||||
latestContentRef={latestContentRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -129,19 +134,20 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
)
|
||||
}
|
||||
|
||||
function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed }: {
|
||||
function EditorBody({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, rawMode, onRawContentChange, onSave, onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, isTrashed, rawLatestContentRef }: {
|
||||
activeTab: Tab | null; isLoadingNewTab: boolean; entries: VaultEntry[]
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
diffMode: boolean; diffContent: string | null; onToggleDiff: () => void
|
||||
rawMode: boolean; onRawContentChange?: (path: string, content: string) => void; onSave?: () => void
|
||||
onNavigateWikilink: (target: string) => void; onEditorChange?: () => void
|
||||
vaultPath?: string; isDarkTheme?: boolean; isTrashed: boolean
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
}) {
|
||||
const showEditor = !diffMode && !rawMode
|
||||
return (
|
||||
<>
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} />
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} isDark={isDarkTheme} latestContentRef={rawLatestContentRef} />
|
||||
{showEditor && activeTab && (
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} editable={!isTrashed} />
|
||||
@@ -157,7 +163,7 @@ export function EditorContent({
|
||||
diffMode, diffContent, onToggleDiff,
|
||||
rawMode, onToggleRaw, onRawContentChange, onSave,
|
||||
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
|
||||
onDeleteNote,
|
||||
onDeleteNote, rawLatestContentRef,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
const isTrashed = activeTab?.entry.trashed ?? false
|
||||
@@ -179,7 +185,7 @@ export function EditorContent({
|
||||
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
|
||||
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
|
||||
)}
|
||||
<EditorBody activeTab={activeTab} isLoadingNewTab={isLoadingNewTab} entries={entries} editor={editor} diffMode={diffMode} diffContent={diffContent} onToggleDiff={onToggleDiff} rawMode={rawMode} onRawContentChange={onRawContentChange} onSave={onSave} onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} isTrashed={isTrashed} />
|
||||
<EditorBody activeTab={activeTab} isLoadingNewTab={isLoadingNewTab} entries={entries} editor={editor} diffMode={diffMode} diffContent={diffContent} onToggleDiff={onToggleDiff} rawMode={rawMode} onRawContentChange={onRawContentChange} onSave={onSave} onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} isTrashed={isTrashed} rawLatestContentRef={rawLatestContentRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ interface EditorRightPanelProps {
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
entries: VaultEntry[]
|
||||
allContent: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
vaultPath: string
|
||||
openTabs?: VaultEntry[]
|
||||
@@ -23,18 +22,20 @@ interface EditorRightPanelProps {
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
export function EditorRightPanel({
|
||||
showAIChat, inspectorCollapsed, inspectorWidth,
|
||||
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath, openTabs,
|
||||
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, openTabs,
|
||||
noteList, noteListFilter,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
|
||||
onFileCreated, onFileModified,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onOpenNote,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
}: EditorRightPanelProps) {
|
||||
if (showAIChat) {
|
||||
return (
|
||||
@@ -47,10 +48,11 @@ export function EditorRightPanel({
|
||||
onOpenNote={onOpenNote}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
vaultPath={vaultPath}
|
||||
activeEntry={inspectorEntry}
|
||||
activeNoteContent={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
openTabs={openTabs}
|
||||
noteList={noteList}
|
||||
noteListFilter={noteListFilter}
|
||||
@@ -72,13 +74,13 @@ export function EditorRightPanel({
|
||||
entry={inspectorEntry}
|
||||
content={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
gitHistory={gitHistory}
|
||||
onNavigate={onNavigateWikilink}
|
||||
onViewCommitDiff={onViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -543,7 +543,7 @@ Status: Active
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={typeEntry}
|
||||
content="---\nIs A: Type\n---\n# Responsibility\n"
|
||||
content="---\ntype: Type\n---\n# Responsibility\n"
|
||||
entries={[typeEntry, essayEntry]}
|
||||
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,8 @@ import { cn } from '@/lib/utils'
|
||||
import { SlidersHorizontal, X } from '@phosphor-icons/react'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||
import { wikilinkTarget } from '../utils/wikilink'
|
||||
import { extractBacklinkContext } from '../utils/wikilinks'
|
||||
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
|
||||
|
||||
export type FrontmatterValue = string | number | boolean | string[] | null
|
||||
@@ -18,20 +17,19 @@ interface InspectorProps {
|
||||
entry: VaultEntry | null
|
||||
content: string | null
|
||||
entries: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
onNavigate: (target: string) => void
|
||||
onViewCommitDiff?: (commitHash: string) => void
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function useBacklinks(
|
||||
entry: VaultEntry | null,
|
||||
entries: VaultEntry[],
|
||||
referencedBy: ReferencedByItem[],
|
||||
allContent?: Record<string, string>,
|
||||
): BacklinkItem[] {
|
||||
return useMemo(() => {
|
||||
if (!entry) return []
|
||||
@@ -53,11 +51,9 @@ function useBacklinks(
|
||||
})
|
||||
.map((e) => ({
|
||||
entry: e,
|
||||
context: allContent?.[e.path]
|
||||
? extractBacklinkContext(allContent[e.path], matchTargets)
|
||||
: null,
|
||||
context: null,
|
||||
}))
|
||||
}, [entry, entries, referencedBy, allContent])
|
||||
}, [entry, entries, referencedBy])
|
||||
}
|
||||
|
||||
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
|
||||
@@ -118,11 +114,11 @@ function EmptyInspector() {
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate,
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
|
||||
}: InspectorProps) {
|
||||
const referencedBy = useReferencedBy(entry, entries)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy, allContent)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy)
|
||||
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
|
||||
const typeEntryMap = useMemo(() => {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
@@ -151,7 +147,7 @@ export function Inspector({
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry} content={content} frontmatter={frontmatter}
|
||||
entries={entries} allContent={allContent}
|
||||
entries={entries}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
@@ -162,7 +158,9 @@ export function Inspector({
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<BacklinksPanel backlinks={backlinks} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||
import type { ReferencedByItem } from './InspectorPanels'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
|
||||
@@ -407,6 +407,190 @@ describe('DynamicRelationshipsPanel', () => {
|
||||
expect(screen.getByTestId('add-relation-ref')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('create & open from inline add', () => {
|
||||
const onUpdateProperty = vi.fn()
|
||||
const onDeleteProperty = vi.fn()
|
||||
const onCreateAndOpenNote = vi.fn<(title: string) => Promise<boolean>>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
onCreateAndOpenNote.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('shows "Create & open" option when typed title does not match any note', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Create & open/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Brand New Note/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Create & open" when typed title matches an existing note', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'AI' } })
|
||||
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreateAndOpenNote and adds wikilink when "Create & open" clicked', async () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Brand New Note')
|
||||
await vi.waitFor(() => {
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Belongs to', ['[[project/my-project]]', '[[Brand New Note]]'])
|
||||
})
|
||||
})
|
||||
|
||||
it('does not add wikilink when note creation fails', async () => {
|
||||
onCreateAndOpenNote.mockResolvedValue(false)
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Failing Note' } })
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('Failing Note')
|
||||
// Give async handler time to resolve
|
||||
await vi.waitFor(() => {
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalled()
|
||||
})
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows both existing matches and "Create & open" for partial matches', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
// "My" partially matches "My Project" but is not an exact match
|
||||
fireEvent.change(input, { target: { value: 'My' } })
|
||||
// Should show search results AND create option
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Create & open" when onCreateAndOpenNote is not provided', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{ 'Belongs to': ['[[project/my-project]]'] }}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('add-relation-ref'))
|
||||
const input = screen.getByTestId('add-relation-ref-input')
|
||||
fireEvent.change(input, { target: { value: 'Brand New Note' } })
|
||||
expect(screen.queryByTestId('create-and-open-option')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('create & open from AddRelationshipForm', () => {
|
||||
const onCreateAndOpenNote = vi.fn<(title: string) => Promise<boolean>>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
onCreateAndOpenNote.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('shows "Create & open" option in target input when title does not exist', () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{}}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Link existing'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
|
||||
const noteInput = screen.getByPlaceholderText('Note title')
|
||||
fireEvent.focus(noteInput)
|
||||
fireEvent.change(noteInput, { target: { value: 'New Person' } })
|
||||
expect(screen.getByTestId('create-and-open-option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates note and adds relationship via form', async () => {
|
||||
render(
|
||||
<DynamicRelationshipsPanel
|
||||
typeEntryMap={{}}
|
||||
frontmatter={{}}
|
||||
entries={entries}
|
||||
onNavigate={onNavigate}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Link existing'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
|
||||
const noteInput = screen.getByPlaceholderText('Note title')
|
||||
fireEvent.focus(noteInput)
|
||||
fireEvent.change(noteInput, { target: { value: 'New Person' } })
|
||||
fireEvent.click(screen.getByTestId('create-and-open-option'))
|
||||
expect(onCreateAndOpenNote).toHaveBeenCalledWith('New Person')
|
||||
await vi.waitFor(() => {
|
||||
expect(onAddProperty).toHaveBeenCalledWith('Mentions', '[[New Person]]')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('BacklinksPanel', () => {
|
||||
@@ -568,3 +752,112 @@ describe('GitHistoryPanel', () => {
|
||||
expect(screen.getByText('10d ago')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InstancesPanel', () => {
|
||||
const onNavigate = vi.fn()
|
||||
const quarterType = makeEntry({
|
||||
path: '/vault/type/quarter.md', filename: 'quarter.md', title: 'Quarter',
|
||||
isA: 'Type', color: 'blue', icon: 'calendar',
|
||||
})
|
||||
const typeEntryMap: Record<string, VaultEntry> = { Quarter: quarterType }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders nothing when entry is not a Type', () => {
|
||||
const entry = makeEntry({ title: 'Random Note', isA: 'Note' })
|
||||
const { container } = render(
|
||||
<InstancesPanel entry={entry} entries={[]} typeEntryMap={{}} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders nothing when Type has zero instances', () => {
|
||||
const { container } = render(
|
||||
<InstancesPanel entry={quarterType} entries={[]} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders instances of a Type sorted by modifiedAt descending', () => {
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
|
||||
makeEntry({ path: '/vault/quarter/q2.md', title: 'Q2 2026', isA: 'Quarter', modifiedAt: 3000 }),
|
||||
makeEntry({ path: '/vault/quarter/q3.md', title: 'Q3 2026', isA: 'Quarter', modifiedAt: 2000 }),
|
||||
]
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(screen.getByText('Instances (3)')).toBeInTheDocument()
|
||||
const buttons = screen.getAllByRole('button').filter(b => ['Q1 2026', 'Q2 2026', 'Q3 2026'].includes(b.textContent?.replace(/\s*\(.*\)/, '') ?? ''))
|
||||
// Q2 (3000) should come before Q3 (2000) before Q1 (1000)
|
||||
expect(buttons[0].textContent).toContain('Q2 2026')
|
||||
expect(buttons[1].textContent).toContain('Q3 2026')
|
||||
expect(buttons[2].textContent).toContain('Q1 2026')
|
||||
})
|
||||
|
||||
it('excludes trashed instances', () => {
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 2000 }),
|
||||
makeEntry({ path: '/vault/quarter/q2.md', title: 'Q2 Trashed', isA: 'Quarter', trashed: true, modifiedAt: 3000 }),
|
||||
]
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(screen.getByText('Q1 2026')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Q2 Trashed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dims archived instances', () => {
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/old.md', title: 'Q4 2024', isA: 'Quarter', archived: true, modifiedAt: 1000 }),
|
||||
]
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(screen.getByTitle('Archived')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when clicking an instance', () => {
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
|
||||
]
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Q1 2026'))
|
||||
expect(onNavigate).toHaveBeenCalledWith('Q1 2026')
|
||||
})
|
||||
|
||||
it('caps display at 50 instances and shows count badge', () => {
|
||||
const instances = Array.from({ length: 80 }, (_, i) =>
|
||||
makeEntry({
|
||||
path: `/vault/quarter/q${i}.md`,
|
||||
title: `Instance ${i}`,
|
||||
isA: 'Quarter',
|
||||
modifiedAt: 80 - i,
|
||||
})
|
||||
)
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(screen.getByText('Instances (80)')).toBeInTheDocument()
|
||||
// Only 50 link buttons rendered
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const instanceButtons = allButtons.filter(b => b.textContent?.startsWith('Instance'))
|
||||
expect(instanceButtons.length).toBe(50)
|
||||
expect(screen.getByText('showing 50 of 80')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show Instances section for non-Type note even if title matches a type name', () => {
|
||||
const notAType = makeEntry({ title: 'Quarter', isA: 'Project' })
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
|
||||
]
|
||||
const { container } = render(
|
||||
<InstancesPanel entry={notAType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,3 +4,4 @@ export type { BacklinkItem } from './inspector/BacklinksPanel'
|
||||
export { ReferencedByPanel } from './inspector/ReferencedByPanel'
|
||||
export type { ReferencedByItem } from './inspector/ReferencedByPanel'
|
||||
export { GitHistoryPanel } from './inspector/GitHistoryPanel'
|
||||
export { InstancesPanel } from './inspector/InstancesPanel'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MarkdownContent } from './MarkdownContent'
|
||||
import { preprocessWikilinks } from '../utils/chatWikilinks'
|
||||
|
||||
describe('MarkdownContent', () => {
|
||||
it('renders bold text', () => {
|
||||
@@ -72,4 +73,110 @@ describe('MarkdownContent', () => {
|
||||
expect(bq).toBeTruthy()
|
||||
expect(bq!.textContent).toContain('A quote')
|
||||
})
|
||||
|
||||
describe('wikilinks', () => {
|
||||
it('preprocessWikilinks converts [[Target]] to markdown links', () => {
|
||||
expect(preprocessWikilinks('See [[My Note]]')).toBe('See [My Note](wikilink://My%20Note)')
|
||||
expect(preprocessWikilinks('[[A]] and [[B]]')).toBe('[A](wikilink://A) and [B](wikilink://B)')
|
||||
expect(preprocessWikilinks('`[[code]]`')).toBe('`[[code]]`')
|
||||
})
|
||||
|
||||
it('renders [[Note Title]] as a clickable wikilink chip', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Check out [[My Note]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')
|
||||
expect(wikilink).toBeTruthy()
|
||||
expect(wikilink!.textContent).toBe('My Note')
|
||||
expect(wikilink!.getAttribute('data-wikilink-target')).toBe('My Note')
|
||||
})
|
||||
|
||||
it('fires onWikilinkClick when a wikilink is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Daily Log]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('Daily Log')
|
||||
})
|
||||
|
||||
it('renders multiple wikilinks in the same paragraph', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Note A]] and [[Note B]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
expect(wikilinks[0].textContent).toBe('Note A')
|
||||
expect(wikilinks[1].textContent).toBe('Note B')
|
||||
})
|
||||
|
||||
it('handles pipe syntax [[target|display]]', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[path/to/note|My Display]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.textContent).toBe('My Display')
|
||||
expect(wikilink.getAttribute('data-wikilink-target')).toBe('path/to/note')
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('path/to/note')
|
||||
})
|
||||
|
||||
it('does not render wikilinks inside inline code', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Use `[[Not a link]]` syntax" onWikilinkClick={onClick} />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render wikilinks inside code blocks', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content={'```\n[[Not a link]]\n```'} onWikilinkClick={onClick} />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles notes with special characters in title', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Check [[Meeting — 2024/01/15]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.textContent).toBe('Meeting — 2024/01/15')
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('Meeting — 2024/01/15')
|
||||
})
|
||||
|
||||
it('does not transform wikilinks when onWikilinkClick is not provided', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Some Note]]" />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
expect(container.textContent).toContain('[[Some Note]]')
|
||||
})
|
||||
|
||||
it('renders wikilinks inside list items', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content={'- First [[Note A]]\n- Second [[Note B]]'} onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('has role="link" and tabIndex for accessibility', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Accessible Note]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.getAttribute('role')).toBe('link')
|
||||
expect(wikilink.getAttribute('tabindex')).toBe('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,63 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import { memo, useMemo, useCallback, type MouseEvent } from 'react'
|
||||
import Markdown, { defaultUrlTransform } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import { preprocessWikilinks, WIKILINK_SCHEME } from '../utils/chatWikilinks'
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
const REHYPE_PLUGINS = [rehypeHighlight]
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) {
|
||||
const rendered = useMemo(() => (
|
||||
<div className="ai-markdown">
|
||||
<Markdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS}>
|
||||
{content}
|
||||
function wikilinkUrlTransform(url: string): string {
|
||||
if (url.startsWith(WIKILINK_SCHEME)) return url
|
||||
return defaultUrlTransform(url)
|
||||
}
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
onWikilinkClick?: (target: string) => void
|
||||
}
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content, onWikilinkClick }: MarkdownContentProps) {
|
||||
const processedContent = useMemo(
|
||||
() => onWikilinkClick ? preprocessWikilinks(content) : content,
|
||||
[content, onWikilinkClick],
|
||||
)
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent) => {
|
||||
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-wikilink-target]')
|
||||
if (el) {
|
||||
e.preventDefault()
|
||||
onWikilinkClick?.(el.dataset.wikilinkTarget!)
|
||||
}
|
||||
}, [onWikilinkClick])
|
||||
|
||||
const components = useMemo(() => {
|
||||
if (!onWikilinkClick) return undefined
|
||||
return {
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
|
||||
if (href?.startsWith(WIKILINK_SCHEME)) {
|
||||
const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length))
|
||||
return (
|
||||
<span className="chat-wikilink" data-wikilink-target={target} role="link" tabIndex={0}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <a href={href}>{children}</a>
|
||||
},
|
||||
}
|
||||
}, [onWikilinkClick])
|
||||
|
||||
return (
|
||||
<div className="ai-markdown" onClick={onWikilinkClick ? handleClick : undefined} role="presentation">
|
||||
<Markdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={components}
|
||||
urlTransform={onWikilinkClick ? wikilinkUrlTransform : undefined}
|
||||
>
|
||||
{processedContent}
|
||||
</Markdown>
|
||||
</div>
|
||||
), [content])
|
||||
return rendered
|
||||
)
|
||||
})
|
||||
|
||||
@@ -90,7 +90,7 @@ function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor:
|
||||
return base
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote }: {
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote, onPrefetch }: {
|
||||
entry: VaultEntry
|
||||
isSelected: boolean
|
||||
isMultiSelected?: boolean
|
||||
@@ -98,6 +98,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
noteStatus?: NoteStatus
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
onPrefetch?: (path: string) => void
|
||||
}) {
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
@@ -114,6 +115,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
)}
|
||||
style={noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
|
||||
onClick={(e: React.MouseEvent) => onClickNote(entry, e)}
|
||||
onMouseEnter={onPrefetch ? () => onPrefetch(entry.path) : undefined}
|
||||
data-testid={isMultiSelected ? 'multi-selected-item' : undefined}
|
||||
data-highlighted={isHighlighted || undefined}
|
||||
>
|
||||
|
||||
@@ -153,38 +153,38 @@ const mockEntries: VaultEntry[] = [
|
||||
|
||||
describe('NoteList', () => {
|
||||
it('shows empty state when no entries', () => {
|
||||
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('No notes found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all entries with All Notes filter', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by People (section group)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by Events (section group)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by section group type', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entity pinned at top with grouped children', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Entity title appears in header and pinned card
|
||||
expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1)
|
||||
@@ -199,7 +199,7 @@ describe('NoteList', () => {
|
||||
|
||||
it('filters by topic (relatedTo references)', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Build Laputa App has relatedTo: [[topic/software-development]]
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
@@ -207,7 +207,7 @@ describe('NoteList', () => {
|
||||
})
|
||||
|
||||
it('shows search input when search icon is clicked', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Search is hidden by default
|
||||
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
|
||||
// Click search icon to show it
|
||||
@@ -216,7 +216,7 @@ describe('NoteList', () => {
|
||||
})
|
||||
|
||||
it('filters by search query (case-insensitive substring)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Open search
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
const input = screen.getByPlaceholderText('Search notes...')
|
||||
@@ -231,31 +231,31 @@ describe('NoteList', () => {
|
||||
{ ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
|
||||
{ ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
|
||||
]
|
||||
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
const titles = screen.getAllByText(/Oldest|Newest|Middle/)
|
||||
const titleTexts = titles.map((el) => el.textContent)
|
||||
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
|
||||
})
|
||||
|
||||
it('does not render type badge or status on note items', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Type badges like "Project", "Note" etc. should not appear as separate badge elements
|
||||
// The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge
|
||||
expect(screen.queryByText('Active')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('header shows search and plus icons instead of count badge', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByTitle('Search notes')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Create new note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('context view shows backlinks from allContent', () => {
|
||||
const allContent = {
|
||||
[mockEntries[2].path]: 'Met with [[project/26q1-laputa-app]] team.',
|
||||
}
|
||||
it('context view shows backlinks from outgoingLinks', () => {
|
||||
const entriesWithBacklink = mockEntries.map(e =>
|
||||
e.path === mockEntries[2].path ? { ...e, outgoingLinks: ['Build Laputa App'] } : e
|
||||
)
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={allContent} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithBacklink} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Backlinks')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
@@ -263,7 +263,7 @@ describe('NoteList', () => {
|
||||
|
||||
it('context view collapses and expands groups', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Children group is expanded by default
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
@@ -278,7 +278,7 @@ describe('NoteList', () => {
|
||||
|
||||
it('context view shows prominent card with snippet subtitle', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Snippet text appears in the prominent card
|
||||
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
|
||||
@@ -292,21 +292,21 @@ describe('NoteList click behavior', () => {
|
||||
})
|
||||
|
||||
it('regular click calls onReplaceActiveTab (opens in current tab)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Click calls onSelectNote (opens in new tab)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Ctrl+Click calls onSelectNote (Windows/Linux)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
@@ -314,7 +314,7 @@ describe('NoteList click behavior', () => {
|
||||
|
||||
it('Cmd+Click on entity pinned card calls onSelectNote', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1], { metaKey: true })
|
||||
@@ -324,7 +324,7 @@ describe('NoteList click behavior', () => {
|
||||
|
||||
it('regular click on entity pinned card calls onReplaceActiveTab', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
@@ -335,7 +335,7 @@ describe('NoteList click behavior', () => {
|
||||
|
||||
it('click on child note in entity view calls onReplaceActiveTab', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[1])
|
||||
@@ -489,21 +489,21 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('shows sort button in note list header for flat view', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows sort dropdown per relationship subsection in entity view', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens sort menu on click and shows all options', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
||||
@@ -520,7 +520,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default sort: by modified (Zebra first)
|
||||
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
||||
@@ -537,7 +537,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('closes sort menu after selecting an option', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
||||
@@ -547,7 +547,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('shows direction arrows in sort dropdown menu', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
// Each option should have asc and desc direction buttons
|
||||
@@ -564,7 +564,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default sort: modified descending (Zebra first at 3000)
|
||||
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
||||
@@ -585,7 +585,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Select title sort with desc direction
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
@@ -598,7 +598,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('shows direction icon on the sort button that reflects current direction', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default: modified desc → should have ArrowDown icon
|
||||
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
|
||||
@@ -635,7 +635,7 @@ describe('NoteList sort controls', () => {
|
||||
const entries = [parent, child1, child2]
|
||||
|
||||
render(
|
||||
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
|
||||
// Default sort: by modified — Zebra Note (3000) before Alpha Note (1000)
|
||||
@@ -657,7 +657,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.getByTestId('sort-separator')).toBeInTheDocument()
|
||||
@@ -668,7 +668,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('omits separator when no custom properties exist', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument()
|
||||
@@ -681,7 +681,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default: modified desc → A, B, C
|
||||
let titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent)
|
||||
@@ -703,7 +703,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.click(screen.getByTestId('sort-option-property:Priority'))
|
||||
@@ -821,7 +821,7 @@ describe('NoteList — status indicators', () => {
|
||||
it('shows modified indicator dot for modified notes', () => {
|
||||
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'modified' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const indicators = screen.getAllByTestId('modified-indicator')
|
||||
expect(indicators).toHaveLength(1)
|
||||
@@ -832,7 +832,7 @@ describe('NoteList — status indicators', () => {
|
||||
it('does not show indicator when all notes are clean', () => {
|
||||
const getNoteStatus = () => 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
|
||||
@@ -842,14 +842,14 @@ describe('NoteList — status indicators', () => {
|
||||
const modifiedPaths = new Set([mockEntries[0].path, mockEntries[1].path])
|
||||
const getNoteStatus = (path: string) => modifiedPaths.has(path) ? 'modified' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show indicator when getNoteStatus prop is undefined', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
|
||||
@@ -858,7 +858,7 @@ describe('NoteList — status indicators', () => {
|
||||
it('shows green new indicator for new notes', () => {
|
||||
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'new' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getAllByTestId('new-indicator')).toHaveLength(1)
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
@@ -869,31 +869,31 @@ describe('NoteList — trash view', () => {
|
||||
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }
|
||||
|
||||
it('shows "Trash" header when trash filter is active', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only trashed entries in trash view', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Old Draft Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows TRASHED badge on trashed entries', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
const badges = screen.getAllByText('TRASHED')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows 30-day warning banner when expired notes exist', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Trash is empty" when no trashed entries', () => {
|
||||
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash is empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -933,7 +933,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
it('renders 9000 entries without crashing', { timeout: 30000 }, () => {
|
||||
const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i))
|
||||
const { container } = render(
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Virtuoso mock renders all items; the real component only renders visible ones
|
||||
expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument()
|
||||
@@ -942,7 +942,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
it('renders items from a large dataset via Virtuoso', () => {
|
||||
const largeDataset = Array.from({ length: 500 }, (_, i) => makeEntry(i))
|
||||
render(
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Note 0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Note 499')).toBeInTheDocument()
|
||||
@@ -955,7 +955,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
makeEntry(999, { title: 'Beta Strategy' }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
|
||||
@@ -971,7 +971,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
...Array.from({ length: 100 }, (_, i) => makeEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default sort is modified desc — Alpha (3000) should come first
|
||||
const firstTitle = screen.getAllByText(/^Alpha$|^Zebra$/)[0]
|
||||
@@ -984,7 +984,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
...Array.from({ length: 200 }, (_, i) => makeEntry(100 + i, { isA: 'Note', title: `Note ${i}` })),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Project 0')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Note 0')).not.toBeInTheDocument()
|
||||
@@ -994,7 +994,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
|
||||
const selected = entries[5]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Note 5')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1003,7 +1003,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
noopReplace.mockClear()
|
||||
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Note 50'))
|
||||
expect(noopReplace).toHaveBeenCalledWith(entries[50])
|
||||
@@ -1018,7 +1018,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
|
||||
it('shows only modified notes in changes view', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
@@ -1028,21 +1028,21 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
|
||||
it('shows header title "Changes"', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state when no modified files', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('No pending changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates list when modifiedFiles changes', () => {
|
||||
const { rerender } = render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
@@ -1050,7 +1050,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
// Simulate one file being committed (removed from modifiedFiles)
|
||||
const fewerModified = [modifiedFiles[0]]
|
||||
rerender(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
||||
@@ -1061,7 +1061,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
// The changes filter must use modifiedFiles for filtering even when getNoteStatus is present.
|
||||
const getNoteStatus = (path: string) => modifiedFiles.some((f) => f.path === path) ? 'modified' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
@@ -1079,7 +1079,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
|
||||
]
|
||||
render(
|
||||
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Even though absolute paths differ, entries should match via relative path suffix
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
@@ -1089,7 +1089,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
|
||||
it('shows error message when modifiedFilesError is set', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/git status failed/)).toBeInTheDocument()
|
||||
@@ -1101,7 +1101,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
|
||||
]
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
@@ -1119,7 +1119,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('Shift+Click selects a range of notes', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Regular click to set anchor
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
// Shift+Click to select range
|
||||
@@ -1130,7 +1130,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('regular click clears multi-select and opens note', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Select range via Shift+click
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
@@ -1142,7 +1142,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('Cmd+Click clears multi-select and opens in new tab', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Select range via Shift+click
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
@@ -1154,7 +1154,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('shows bulk action bar with correct count', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
@@ -1163,7 +1163,7 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it('bulk archive calls onBulkArchive and clears selection', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-archive-btn'))
|
||||
@@ -1173,7 +1173,7 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it('bulk trash calls onBulkTrash and clears selection', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-trash-btn'))
|
||||
@@ -1182,7 +1182,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('clear button on bulk action bar clears selection', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
@@ -1193,7 +1193,7 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it('Cmd+E archives selected notes when multiselect is active', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
@@ -1204,7 +1204,7 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it('Cmd+Backspace trashes selected notes when multiselect is active', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
@@ -1215,7 +1215,7 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it('Cmd+Delete trashes selected notes when multiselect is active', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.keyDown(window, { key: 'Delete', metaKey: true })
|
||||
@@ -1224,7 +1224,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('no bulk action bar when nothing is selected', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1269,7 +1269,7 @@ describe('NoteList — type note filtering', () => {
|
||||
|
||||
it('does not show type note PinnedCard when browsing a sectionGroup', () => {
|
||||
render(
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// The type note snippet should NOT be visible (PinnedCard was removed)
|
||||
expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument()
|
||||
@@ -1279,7 +1279,7 @@ describe('NoteList — type note filtering', () => {
|
||||
|
||||
it('shows clickable header title that navigates to type note', () => {
|
||||
render(
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const headerLink = screen.getByTestId('type-header-link')
|
||||
expect(headerLink).toBeInTheDocument()
|
||||
@@ -1291,7 +1291,7 @@ describe('NoteList — type note filtering', () => {
|
||||
|
||||
it('header is not clickable when not viewing a type section', () => {
|
||||
render(
|
||||
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1300,7 +1300,7 @@ describe('NoteList — type note filtering', () => {
|
||||
describe('NoteList — traffic light padding when sidebar collapsed', () => {
|
||||
it('adds left padding to header when sidebarCollapsed is true', () => {
|
||||
const { container } = render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} sidebarCollapsed={true} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={true} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('80px')
|
||||
@@ -1308,7 +1308,7 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
|
||||
|
||||
it('does not add extra left padding when sidebarCollapsed is false', () => {
|
||||
const { container } = render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} sidebarCollapsed={false} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={false} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('')
|
||||
@@ -1316,7 +1316,7 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
|
||||
|
||||
it('does not add extra left padding when sidebarCollapsed is not provided', () => {
|
||||
const { container } = render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('')
|
||||
|
||||
@@ -4,10 +4,11 @@ import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning,
|
||||
MagnifyingGlass, Plus, CaretDown, CaretRight, Warning, Trash,
|
||||
} from '@phosphor-icons/react'
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { NoteItem, getTypeIcon } from './NoteItem'
|
||||
import { prefetchNoteContent } from '../hooks/useTabManagement'
|
||||
import { SortDropdown } from './SortDropdown'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
import { useMultiSelect, type MultiSelectState } from '../hooks/useMultiSelect'
|
||||
@@ -25,7 +26,6 @@ interface NoteListProps {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
selectedNote: VaultEntry | null
|
||||
allContent: Record<string, string>
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
modifiedFilesError?: string | null
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
@@ -35,6 +35,9 @@ interface NoteListProps {
|
||||
onCreateNote: () => void
|
||||
onBulkArchive?: (paths: string[]) => void
|
||||
onBulkTrash?: (paths: string[]) => void
|
||||
onBulkRestore?: (paths: string[]) => void
|
||||
onBulkDeletePermanently?: (paths: string[]) => void
|
||||
onEmptyTrash?: () => void
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
}
|
||||
@@ -241,7 +244,7 @@ function toggleSetMember<T>(set: Set<T>, member: T): Set<T> {
|
||||
// --- Data hooks ---
|
||||
|
||||
interface NoteListDataParams {
|
||||
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
|
||||
entries: VaultEntry[]; selection: SidebarSelection
|
||||
query: string; listSort: SortOption; listDirection: SortDirection
|
||||
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
|
||||
}
|
||||
@@ -261,7 +264,7 @@ function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection,
|
||||
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes])
|
||||
}
|
||||
|
||||
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
|
||||
function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
|
||||
|
||||
@@ -274,9 +277,9 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
|
||||
|
||||
const searchedGroups = useMemo(() => {
|
||||
if (!isEntityView) return []
|
||||
const groups = buildRelationshipGroups(selection.entry, entries, allContent)
|
||||
const groups = buildRelationshipGroups(selection.entry, entries)
|
||||
return filterGroupsByQuery(groups, query)
|
||||
}, [isEntityView, selection, entries, allContent, query])
|
||||
}, [isEntityView, selection, entries, query])
|
||||
|
||||
const expiredTrashCount = useMemo(
|
||||
() => isTrashView ? countExpiredTrash(searched) : 0,
|
||||
@@ -423,10 +426,12 @@ function useMultiSelectKeyboard(multiSelect: MultiSelectState, isEntityView: boo
|
||||
|
||||
// --- Header component ---
|
||||
|
||||
function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: {
|
||||
function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash }: {
|
||||
title: string
|
||||
typeDocument: VaultEntry | null
|
||||
isEntityView: boolean
|
||||
isTrashView: boolean
|
||||
trashCount: number
|
||||
listSort: SortOption
|
||||
listDirection: SortDirection
|
||||
customProperties: string[]
|
||||
@@ -438,6 +443,7 @@ function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirec
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
onToggleSearch: () => void
|
||||
onSearchChange: (value: string) => void
|
||||
onEmptyTrash?: () => void
|
||||
}) {
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
return (
|
||||
@@ -456,9 +462,21 @@ function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirec
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onToggleSearch} title="Search notes">
|
||||
<MagnifyingGlass size={16} />
|
||||
</button>
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
{isTrashView && trashCount > 0 && (
|
||||
<button
|
||||
className="flex items-center text-destructive transition-colors hover:text-destructive/80"
|
||||
onClick={onEmptyTrash}
|
||||
title="Empty Trash"
|
||||
data-testid="empty-trash-btn"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
)}
|
||||
{!isTrashView && (
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{searchVisible && (
|
||||
@@ -484,14 +502,14 @@ function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, getNot
|
||||
return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry })
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
|
||||
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null
|
||||
|
||||
@@ -505,10 +523,14 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
|
||||
const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
|
||||
const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
|
||||
useMultiSelectKeyboard(multiSelect, isEntityView, handleBulkArchive, handleBulkTrash)
|
||||
const handleBulkRestore = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkRestore?.(paths) }, [multiSelect, onBulkRestore])
|
||||
const handleBulkDeletePermanently = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkDeletePermanently?.(paths) }, [multiSelect, onBulkDeletePermanently])
|
||||
const bulkArchiveOrRestore = isTrashView ? handleBulkRestore : handleBulkArchive
|
||||
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
|
||||
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry) => (
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
|
||||
|
||||
const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
|
||||
@@ -516,7 +538,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
|
||||
return (
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} />
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
<div className="flex-1 overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
{entitySelection ? (
|
||||
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
@@ -525,7 +547,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
)}
|
||||
</div>
|
||||
{multiSelect.isMultiSelecting && (
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onClear={multiSelect.clear} />
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onClear={multiSelect.clear} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface RawEditorViewProps {
|
||||
onContentChange: (path: string, content: string) => void
|
||||
onSave: () => void
|
||||
isDark?: boolean
|
||||
/** Mutable ref updated on every keystroke with the latest doc string.
|
||||
* Allows the parent to flush debounced content before unmount. */
|
||||
latestContentRef?: React.MutableRefObject<string | null>
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 500
|
||||
@@ -35,7 +38,7 @@ function getCursorCoords(view: EditorView): { top: number; left: number } | null
|
||||
return { top: coords.bottom, left: coords.left }
|
||||
}
|
||||
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave, isDark = false }: RawEditorViewProps) {
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave, isDark = false, latestContentRef }: RawEditorViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pathRef = useRef(path)
|
||||
@@ -43,6 +46,8 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
const onSaveRef = useRef(onSave)
|
||||
const latestDocRef = useRef(content)
|
||||
useEffect(() => { pathRef.current = path }, [path])
|
||||
// Expose latest doc content to parent via ref
|
||||
useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content])
|
||||
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
|
||||
useEffect(() => { onSaveRef.current = onSave }, [onSave])
|
||||
|
||||
@@ -52,7 +57,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
() => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
@@ -64,8 +69,12 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
|
||||
const insertWikilinkRef = useRef<(entryTitle: string) => void>(() => {})
|
||||
|
||||
const latestContentRefStable = useRef(latestContentRef)
|
||||
useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef])
|
||||
|
||||
const handleDocChange = useCallback((doc: string) => {
|
||||
latestDocRef.current = doc
|
||||
if (latestContentRefStable.current) latestContentRefStable.current.current = doc
|
||||
setYamlError(detectYamlError(doc))
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
|
||||
73
src/components/Sidebar.test.ts
Normal file
73
src/components/Sidebar.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildSectionGroup } from '../utils/sidebarSections'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { GearSix, CookingPot, FileText } from '@phosphor-icons/react'
|
||||
|
||||
const baseEntry: VaultEntry = {
|
||||
path: '', filename: '', title: '', isA: null, aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
}
|
||||
|
||||
describe('buildSectionGroup', () => {
|
||||
it('uses type entry icon/color/sidebarLabel for custom type', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'blue', sidebarLabel: 'Config' },
|
||||
}
|
||||
const group = buildSectionGroup('Config', typeEntryMap)
|
||||
expect(group.label).toBe('Config')
|
||||
expect(group.customColor).toBe('blue')
|
||||
expect(group.Icon).toBe(GearSix)
|
||||
})
|
||||
|
||||
it('uses type entry icon/color for custom type with custom icon', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Recipe: { ...baseEntry, title: 'Recipe', isA: 'Type', icon: 'cooking-pot', color: 'orange' },
|
||||
}
|
||||
const group = buildSectionGroup('Recipe', typeEntryMap)
|
||||
expect(group.label).toBe('Recipes')
|
||||
expect(group.customColor).toBe('orange')
|
||||
expect(group.Icon).toBe(CookingPot)
|
||||
})
|
||||
|
||||
it('falls back to pluralized name and FileText when no type entry', () => {
|
||||
const group = buildSectionGroup('Widget', {})
|
||||
expect(group.label).toBe('Widgets')
|
||||
expect(group.customColor).toBeNull()
|
||||
expect(group.Icon).toBe(FileText)
|
||||
})
|
||||
|
||||
it('overrides built-in type icon/color when type entry has custom values', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Project: { ...baseEntry, title: 'Project', isA: 'Type', icon: 'rocket', color: 'green', sidebarLabel: 'My Projects' },
|
||||
}
|
||||
const group = buildSectionGroup('Project', typeEntryMap)
|
||||
expect(group.label).toBe('My Projects')
|
||||
expect(group.customColor).toBe('green')
|
||||
expect(group.Icon).toBe(resolveIcon('rocket'))
|
||||
})
|
||||
|
||||
it('uses gray color for Config type', () => {
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
|
||||
}
|
||||
const group = buildSectionGroup('Config', typeEntryMap)
|
||||
expect(group.customColor).toBe('gray')
|
||||
})
|
||||
|
||||
it('resolves type entry via lowercase key (case-insensitive isA)', () => {
|
||||
// When instances have isA: 'config' (lowercase) but type entry title is 'Config'
|
||||
const typeEntryMap: Record<string, VaultEntry> = {
|
||||
Config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
|
||||
config: { ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
|
||||
}
|
||||
const group = buildSectionGroup('config', typeEntryMap)
|
||||
expect(group.label).toBe('Config')
|
||||
expect(group.customColor).toBe('gray')
|
||||
expect(group.Icon).toBe(GearSix)
|
||||
})
|
||||
})
|
||||
@@ -233,10 +233,10 @@ const mockEntries: VaultEntry[] = [
|
||||
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('renders top nav items (All Notes and Favorites)', () => {
|
||||
it('renders top nav items (All Notes)', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders section group headers only for types present in entries', () => {
|
||||
@@ -764,7 +764,7 @@ describe('Sidebar', () => {
|
||||
]
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a "Customize sections" button', () => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { pluralizeType } from '../hooks/useCommandRegistry'
|
||||
import { buildDynamicSections, sortSections } from '../utils/sidebarSections'
|
||||
import { TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
||||
@@ -13,8 +12,7 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
|
||||
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -41,20 +39,6 @@ interface SidebarProps {
|
||||
isGitVault?: boolean
|
||||
}
|
||||
|
||||
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
|
||||
{ label: 'Projects', type: 'Project', Icon: Wrench },
|
||||
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
|
||||
{ label: 'Responsibilities', type: 'Responsibility', Icon: Target },
|
||||
{ label: 'Procedures', type: 'Procedure', Icon: ArrowsClockwise },
|
||||
{ label: 'People', type: 'Person', Icon: Users },
|
||||
{ label: 'Events', type: 'Event', Icon: CalendarBlank },
|
||||
{ label: 'Topics', type: 'Topic', Icon: Tag },
|
||||
{ label: 'Types', type: 'Type', Icon: StackSimple },
|
||||
]
|
||||
|
||||
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
|
||||
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
|
||||
|
||||
// --- Hooks ---
|
||||
|
||||
function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
|
||||
@@ -68,42 +52,6 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
|
||||
}, [ref, isOpen, onClose])
|
||||
}
|
||||
|
||||
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
|
||||
function collectActiveTypes(entries: VaultEntry[]): Set<string> {
|
||||
const types = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
|
||||
function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
|
||||
const builtIn = BUILT_IN_TYPE_MAP.get(type)
|
||||
const typeEntry = typeEntryMap[type]
|
||||
const customColor = typeEntry?.color ?? null
|
||||
const label = typeEntry?.sidebarLabel || (builtIn?.label ?? pluralizeType(type))
|
||||
if (builtIn) {
|
||||
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
|
||||
return { ...builtIn, label, Icon, customColor }
|
||||
}
|
||||
return { label, type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
|
||||
}
|
||||
|
||||
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
|
||||
function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
const activeTypes = collectActiveTypes(entries)
|
||||
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
|
||||
}
|
||||
|
||||
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
return [...groups].sort((a, b) => {
|
||||
const orderA = typeEntryMap[a.type]?.order ?? Infinity
|
||||
const orderB = typeEntryMap[b.type]?.order ?? Infinity
|
||||
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
|
||||
})
|
||||
}
|
||||
|
||||
function useSidebarSections(entries: VaultEntry[]) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const allSectionGroups = useMemo(() => {
|
||||
@@ -356,9 +304,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
{/* Top nav */}
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
|
||||
<NavItem icon={Star} label="Favorites" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'favorites' })} onClick={() => onSelect({ kind: 'filter', filter: 'favorites' })} />
|
||||
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
|
||||
<NavItem icon={TagSimple} label="Untagged" disabled />
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
{modifiedCount > 0 && (
|
||||
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
|
||||
|
||||
@@ -39,6 +39,17 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
const onImageUrl = useInsertImageCallback(editor)
|
||||
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
|
||||
|
||||
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!editable) return
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('[contenteditable="true"]')) return
|
||||
const blocks = editor.document
|
||||
if (blocks.length > 0) {
|
||||
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
|
||||
}
|
||||
editor.focus()
|
||||
}, [editor, editable])
|
||||
|
||||
useEffect(() => {
|
||||
_wikilinkEntriesRef.current = entries
|
||||
}, [entries])
|
||||
@@ -62,7 +73,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
() => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
@@ -94,7 +105,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
}, [baseItems, insertWikilink, typeEntryMap])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties}>
|
||||
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>
|
||||
{isDragOver && (
|
||||
<div className="editor__drop-overlay">
|
||||
<div className="editor__drop-overlay-label">Drop image here</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { StatusBar } from './StatusBar'
|
||||
import type { VaultOption } from './StatusBar'
|
||||
import { formatIndexedElapsed } from '../utils/indexingHelpers'
|
||||
|
||||
vi.mock('../utils/url', async () => {
|
||||
const actual = await vi.importActual('../utils/url')
|
||||
@@ -394,4 +395,71 @@ describe('StatusBar', () => {
|
||||
fireEvent.click(screen.getByTestId('status-mcp'))
|
||||
expect(onInstallMcp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows "Indexed just now" when lastIndexedTime is recent and phase is idle', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
lastIndexedTime={Date.now() - 5000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Indexed just now/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-indexed-time')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onReindexVault when clicking the indexed time badge', () => {
|
||||
const onReindexVault = vi.fn()
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
lastIndexedTime={Date.now() - 5000}
|
||||
onReindexVault={onReindexVault}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('status-indexed-time'))
|
||||
expect(onReindexVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides indexed time badge when no lastIndexedTime', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexed-time')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatIndexedElapsed', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatIndexedElapsed(null)).toBe('')
|
||||
})
|
||||
|
||||
it('returns "Indexed just now" for < 60s', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 30_000)).toBe('Indexed just now')
|
||||
})
|
||||
|
||||
it('returns minutes for < 60min', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 5 * 60_000)).toBe('Indexed 5m ago')
|
||||
})
|
||||
|
||||
it('returns hours for < 24h', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 3 * 3600_000)).toBe('Indexed 3h ago')
|
||||
})
|
||||
|
||||
it('returns days for >= 24h', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 48 * 3600_000)).toBe('Indexed 2d ago')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { LastCommitInfo, SyncStatus } from '../types'
|
||||
import type { IndexingProgress } from '../hooks/useIndexing'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import { formatIndexedElapsed } from '../utils/indexingHelpers'
|
||||
|
||||
export interface VaultOption {
|
||||
label: string
|
||||
@@ -33,7 +34,9 @@ interface StatusBarProps {
|
||||
buildNumber?: string
|
||||
onCheckForUpdates?: () => void
|
||||
indexingProgress?: IndexingProgress
|
||||
lastIndexedTime?: number | null
|
||||
onRetryIndexing?: () => void
|
||||
onReindexVault?: () => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
mcpStatus?: McpStatus
|
||||
onInstallMcp?: () => void
|
||||
@@ -245,8 +248,32 @@ const INDEXING_LABELS: Record<string, string> = {
|
||||
unavailable: 'Search unavailable',
|
||||
}
|
||||
|
||||
function IndexingBadge({ progress, onRetry }: { progress: IndexingProgress; onRetry?: () => void }) {
|
||||
if (progress.phase === 'idle' || progress.phase === 'unavailable') return null
|
||||
function IndexingBadge({ progress, lastIndexedTime, onRetry, onReindex }: { progress: IndexingProgress; lastIndexedTime?: number | null; onRetry?: () => void; onReindex?: () => void }) {
|
||||
const isIdle = progress.phase === 'idle' || progress.phase === 'unavailable'
|
||||
|
||||
// When idle, show "Indexed Xm ago" if we have a timestamp
|
||||
if (isIdle) {
|
||||
if (!lastIndexedTime) return null
|
||||
const elapsed = formatIndexedElapsed(lastIndexedTime)
|
||||
if (!elapsed) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={onReindex ? 'button' : undefined}
|
||||
onClick={onReindex}
|
||||
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', cursor: onReindex ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title={onReindex ? 'Click to reindex vault' : undefined}
|
||||
data-testid="status-indexed-time"
|
||||
onMouseEnter={onReindex ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onReindex ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
>
|
||||
<Search size={13} />{elapsed}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
|
||||
const isActive = !progress.done
|
||||
const isError = progress.phase === 'error'
|
||||
@@ -329,7 +356,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, onRetryIndexing, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
@@ -355,7 +382,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
{indexingProgress && <IndexingBadge progress={indexingProgress} onRetry={onRetryIndexing} />}
|
||||
{indexingProgress && <IndexingBadge progress={indexingProgress} lastIndexedTime={lastIndexedTime} onRetry={onRetryIndexing} onReindex={onReindexVault} />}
|
||||
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
|
||||
@@ -218,6 +218,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-tab-path={tab.entry.path}
|
||||
draggable={!isEditing}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
|
||||
@@ -8,7 +8,7 @@ function makeThemeManager(overrides: Partial<ThemeManager> = {}): ThemeManager {
|
||||
themes: [],
|
||||
activeThemeId: '/vault/_themes/My Theme.md',
|
||||
activeTheme: { id: '/vault/_themes/My Theme.md', name: 'My Theme', description: '', colors: {}, typography: {}, spacing: {} },
|
||||
activeThemeContent: '---\nIs A: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
|
||||
activeThemeContent: '---\ntype: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
|
||||
isDark: false,
|
||||
switchTheme: vi.fn(),
|
||||
createTheme: vi.fn().mockResolvedValue(''),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useState, useRef, useMemo, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { bestSearchRank } from '../utils/fuzzyMatch'
|
||||
|
||||
const MAX_SUGGESTIONS = 20
|
||||
const MIN_QUERY_LENGTH = 1
|
||||
@@ -46,13 +47,16 @@ function matchEntries(
|
||||
): SuggestionEntry[] {
|
||||
if (query.length < MIN_QUERY_LENGTH) return []
|
||||
const lower = query.toLowerCase()
|
||||
const matches = entries.filter(e =>
|
||||
!e.trashed && !e.archived && (
|
||||
e.title.toLowerCase().includes(lower) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lower))
|
||||
),
|
||||
)
|
||||
return matches.slice(0, MAX_SUGGESTIONS).map(e => {
|
||||
const matches = entries
|
||||
.filter(e =>
|
||||
!e.trashed && !e.archived && (
|
||||
e.title.toLowerCase().includes(lower) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lower))
|
||||
),
|
||||
)
|
||||
.map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) }))
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return {
|
||||
title: e.title,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable react-refresh/only-export-components -- module-level schema, not a component file */
|
||||
import { BlockNoteSchema, defaultInlineContentSpecs } from '@blocknote/core'
|
||||
import { createReactInlineContentSpec } from '@blocknote/react'
|
||||
import { resolveWikilinkColor as resolveColor, findEntryByTarget } from '../utils/wikilinkColors'
|
||||
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// Module-level cache so the WikiLink renderer (defined outside React) can access entries
|
||||
@@ -16,7 +17,7 @@ function resolveWikilinkColor(target: string) {
|
||||
function resolveDisplayText(target: string): string {
|
||||
const pipeIdx = target.indexOf('|')
|
||||
if (pipeIdx !== -1) return target.slice(pipeIdx + 1)
|
||||
const entry = findEntryByTarget(_wikilinkEntriesRef.current, target)
|
||||
const entry = resolveEntry(_wikilinkEntriesRef.current, target)
|
||||
if (entry) return entry.title
|
||||
const last = target.split('/').pop() ?? target
|
||||
return last.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
57
src/components/inspector/InstancesPanel.tsx
Normal file
57
src/components/inspector/InstancesPanel.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { getTypeColor } from '../../utils/typeColors'
|
||||
import { getTypeIcon } from '../NoteItem'
|
||||
import { LinkButton } from './LinkButton'
|
||||
import { entryStatusTitle } from './shared'
|
||||
|
||||
const MAX_DISPLAY = 50
|
||||
|
||||
export function InstancesPanel({ entry, entries, typeEntryMap, onNavigate }: {
|
||||
entry: VaultEntry
|
||||
entries: VaultEntry[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const instances = useMemo(() => {
|
||||
if (entry.isA !== 'Type') return []
|
||||
return entries
|
||||
.filter((e) => e.isA === entry.title && !e.trashed)
|
||||
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
|
||||
}, [entry, entries])
|
||||
|
||||
if (instances.length === 0) return null
|
||||
|
||||
const displayed = instances.slice(0, MAX_DISPLAY)
|
||||
const total = instances.length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="font-mono-overline mb-1 block text-muted-foreground">
|
||||
Instances ({total})
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{displayed.map((e) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return (
|
||||
<LinkButton
|
||||
key={e.path}
|
||||
label={e.title}
|
||||
typeColor={getTypeColor(e.isA, te?.color)}
|
||||
isArchived={e.archived}
|
||||
isTrashed={false}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={entryStatusTitle(e)}
|
||||
TypeIcon={getTypeIcon(e.isA, te?.icon)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{total > MAX_DISPLAY && (
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">
|
||||
showing {MAX_DISPLAY} of {total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +1,138 @@
|
||||
import { useMemo, useCallback, useState, useRef } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { X } from '@phosphor-icons/react'
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import type { ParsedFrontmatter } from '../../utils/frontmatter'
|
||||
import { RELATIONSHIP_KEYS, containsWikilinks } from '../DynamicPropertiesPanel'
|
||||
import { containsWikilinks } from '../DynamicPropertiesPanel'
|
||||
import type { FrontmatterValue } from '../Inspector'
|
||||
import { NoteSearchList } from '../NoteSearchList'
|
||||
import { useNoteSearch } from '../../hooks/useNoteSearch'
|
||||
import { resolveEntry } from '../../utils/wikilink'
|
||||
import { isWikilink, resolveRefProps } from './shared'
|
||||
import { LinkButton } from './LinkButton'
|
||||
|
||||
function SearchDropdown({ search, onSelect }: {
|
||||
search: ReturnType<typeof useNoteSearch>
|
||||
onSelect: (title: string) => void
|
||||
/** Check whether any entry resolves for the given title (exact match via wikilink resolution). */
|
||||
function hasExactTitleMatch(entries: VaultEntry[], title: string): boolean {
|
||||
return resolveEntry(entries, title) !== undefined
|
||||
}
|
||||
|
||||
function CreateAndOpenOption({ title, selected, onClick, onHover }: {
|
||||
title: string
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
onHover: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
|
||||
<NoteSearchList
|
||||
items={search.results}
|
||||
selectedIndex={search.selectedIndex}
|
||||
getItemKey={(item) => item.entry.path}
|
||||
onItemClick={(item) => onSelect(item.entry.title)}
|
||||
onItemHover={(i) => search.setSelectedIndex(i)}
|
||||
className="max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm transition-colors ${selected ? 'bg-accent' : 'hover:bg-secondary'}`}
|
||||
data-testid="create-and-open-option"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onHover}
|
||||
>
|
||||
<Plus size={14} className="shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-foreground">
|
||||
Create & open <strong>{title}</strong>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineAddNote({ entries, onAdd }: {
|
||||
function SearchDropdownWithCreate({ search, onSelect, query, entries, onCreateAndOpen }: {
|
||||
search: ReturnType<typeof useNoteSearch>
|
||||
onSelect: (title: string) => void
|
||||
query: string
|
||||
entries: VaultEntry[]
|
||||
onCreateAndOpen?: (title: string) => void
|
||||
}) {
|
||||
const trimmed = query.trim()
|
||||
const showCreate = !!onCreateAndOpen && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed)
|
||||
const hasResults = search.results.length > 0
|
||||
const createIndex = search.results.length
|
||||
|
||||
if (!hasResults && !showCreate) return null
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
|
||||
{hasResults && (
|
||||
<NoteSearchList
|
||||
items={search.results}
|
||||
selectedIndex={search.selectedIndex}
|
||||
getItemKey={(item) => item.entry.path}
|
||||
onItemClick={(item) => onSelect(item.entry.title)}
|
||||
onItemHover={(i) => search.setSelectedIndex(i)}
|
||||
className="max-h-[160px] overflow-y-auto"
|
||||
/>
|
||||
)}
|
||||
{showCreate && (
|
||||
<CreateAndOpenOption
|
||||
title={trimmed}
|
||||
selected={search.selectedIndex === createIndex}
|
||||
onClick={() => onCreateAndOpen(trimmed)}
|
||||
onHover={() => search.setSelectedIndex(createIndex)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
|
||||
entries: VaultEntry[]
|
||||
onAdd: (noteTitle: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const [active, setActive] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const search = useNoteSearch(entries, query, 8)
|
||||
|
||||
const trimmed = query.trim()
|
||||
const showCreate = !!onCreateAndOpenNote && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed)
|
||||
const createIndex = search.results.length
|
||||
|
||||
const selectAndClose = useCallback((title: string) => {
|
||||
onAdd(title)
|
||||
setQuery('')
|
||||
setActive(false)
|
||||
}, [onAdd])
|
||||
|
||||
const handleCreateAndOpen = useCallback(async () => {
|
||||
if (!onCreateAndOpenNote) return
|
||||
const title = trimmed
|
||||
if (!title) return
|
||||
const ok = await onCreateAndOpenNote(title)
|
||||
if (ok) {
|
||||
onAdd(title)
|
||||
setQuery('')
|
||||
setActive(false)
|
||||
}
|
||||
}, [onCreateAndOpenNote, trimmed, onAdd])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
const title = search.selectedEntry?.title ?? query.trim()
|
||||
if (showCreate && search.selectedIndex === createIndex) {
|
||||
handleCreateAndOpen()
|
||||
return
|
||||
}
|
||||
const title = search.selectedEntry?.title ?? trimmed
|
||||
if (title) selectAndClose(title)
|
||||
}, [search.selectedEntry, query, selectAndClose])
|
||||
}, [search.selectedEntry, search.selectedIndex, trimmed, selectAndClose, showCreate, createIndex, handleCreateAndOpen])
|
||||
|
||||
const totalItems = search.results.length + (showCreate ? 1 : 0)
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
search.handleKeyDown(e)
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleConfirm() }
|
||||
else if (e.key === 'Escape') { setQuery(''); setActive(false) }
|
||||
}, [search, handleConfirm])
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
search.setSelectedIndex((i: number) => Math.min(i + 1, totalItems - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
search.setSelectedIndex((i: number) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleConfirm()
|
||||
} else if (e.key === 'Escape') {
|
||||
setQuery('')
|
||||
setActive(false)
|
||||
}
|
||||
}, [search, totalItems, handleConfirm])
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
@@ -66,6 +147,8 @@ function InlineAddNote({ entries, onAdd }: {
|
||||
)
|
||||
}
|
||||
|
||||
const showDropdown = query.trim().length > 0 && (search.results.length > 0 || showCreate)
|
||||
|
||||
return (
|
||||
<div className="relative mt-1">
|
||||
<div className="group/add relative flex items-center">
|
||||
@@ -87,17 +170,31 @@ function InlineAddNote({ entries, onAdd }: {
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{query.trim() && search.results.length > 0 && (
|
||||
<SearchDropdown search={search} onSelect={selectAndClose} />
|
||||
{showDropdown && (
|
||||
<SearchDropdownWithCreate
|
||||
search={search}
|
||||
onSelect={selectAndClose}
|
||||
query={query}
|
||||
entries={entries}
|
||||
onCreateAndOpen={onCreateAndOpenNote ? (title) => {
|
||||
const fn = async () => {
|
||||
const ok = await onCreateAndOpenNote(title)
|
||||
if (ok) { onAdd(title); setQuery(''); setActive(false) }
|
||||
}
|
||||
fn()
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef }: {
|
||||
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef, onCreateAndOpenNote }: {
|
||||
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
onRemoveRef?: (ref: string) => void; onAddRef?: (noteTitle: string) => void
|
||||
onRemoveRef?: (ref: string) => void
|
||||
onAddRef?: (noteTitle: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
if (refs.length === 0) return null
|
||||
return (
|
||||
@@ -116,14 +213,20 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{onAddRef && <InlineAddNote entries={entries} onAdd={onAddRef} />}
|
||||
{onAddRef && (
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
onAdd={onAddRef}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string; refs: string[] }[] {
|
||||
return Object.entries(frontmatter)
|
||||
.filter(([key, value]) => key !== 'Type' && (RELATIONSHIP_KEYS.has(key) || containsWikilinks(value)))
|
||||
.filter(([key, value]) => key !== 'Type' && containsWikilinks(value))
|
||||
.map(([key, value]) => {
|
||||
const refs: string[] = []
|
||||
if (typeof value === 'string' && isWikilink(value)) refs.push(value)
|
||||
@@ -133,26 +236,46 @@ function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string;
|
||||
.filter(({ refs }) => refs.length > 0)
|
||||
}
|
||||
|
||||
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: {
|
||||
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel, onCreateAndOpenNote, onSubmitWithCreate }: {
|
||||
entries: VaultEntry[]
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onSubmit?: () => void
|
||||
onCancel?: () => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onSubmitWithCreate?: (title: string) => void
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false)
|
||||
const search = useNoteSearch(entries, value, 8)
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
search.handleKeyDown(e)
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (search.selectedEntry) { onChange(search.selectedEntry.title); setFocused(false) }
|
||||
else onSubmit?.()
|
||||
} else if (e.key === 'Escape') { onCancel?.() }
|
||||
}, [search, onChange, onSubmit, onCancel])
|
||||
const trimmed = value.trim()
|
||||
const showCreate = !!onCreateAndOpenNote && trimmed.length > 0 && !hasExactTitleMatch(entries, trimmed)
|
||||
const createIndex = search.results.length
|
||||
const totalItems = search.results.length + (showCreate ? 1 : 0)
|
||||
|
||||
const showDropdown = focused && value.trim() && search.results.length > 0
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
search.setSelectedIndex((i: number) => Math.min(i + 1, totalItems - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
search.setSelectedIndex((i: number) => Math.max(i - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (showCreate && search.selectedIndex === createIndex) {
|
||||
onSubmitWithCreate?.(trimmed)
|
||||
} else if (search.selectedEntry) {
|
||||
onChange(search.selectedEntry.title)
|
||||
setFocused(false)
|
||||
} else {
|
||||
onSubmit?.()
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel?.()
|
||||
}
|
||||
}, [search, totalItems, showCreate, createIndex, trimmed, onChange, onSubmit, onCancel, onSubmitWithCreate])
|
||||
|
||||
const showDropdown = focused && trimmed.length > 0 && (search.results.length > 0 || showCreate)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -167,15 +290,22 @@ function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: {
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{showDropdown && (
|
||||
<SearchDropdown search={search} onSelect={(title) => { onChange(title); setFocused(false) }} />
|
||||
<SearchDropdownWithCreate
|
||||
search={search}
|
||||
onSelect={(title) => { onChange(title); setFocused(false) }}
|
||||
query={value}
|
||||
entries={entries}
|
||||
onCreateAndOpen={onCreateAndOpenNote ? (title) => onSubmitWithCreate?.(title) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddRelationshipForm({ entries, onAddProperty }: {
|
||||
function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
|
||||
entries: VaultEntry[]
|
||||
onAddProperty: (key: string, value: FrontmatterValue) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const [relKey, setRelKey] = useState('')
|
||||
const [relTarget, setRelTarget] = useState('')
|
||||
@@ -190,6 +320,17 @@ function AddRelationshipForm({ entries, onAddProperty }: {
|
||||
setRelKey(''); setRelTarget(''); setShowForm(false)
|
||||
}, [relKey, relTarget, onAddProperty])
|
||||
|
||||
const handleCreateAndSubmit = useCallback(async (title: string) => {
|
||||
if (!onCreateAndOpenNote) return
|
||||
const key = relKey.trim()
|
||||
if (!key) return
|
||||
const ok = await onCreateAndOpenNote(title)
|
||||
if (ok) {
|
||||
onAddProperty(key, `[[${title}]]`)
|
||||
setRelKey(''); setRelTarget(''); setShowForm(false)
|
||||
}
|
||||
}, [onCreateAndOpenNote, relKey, onAddProperty])
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setShowForm(false); setRelKey(''); setRelTarget('')
|
||||
}, [])
|
||||
@@ -212,7 +353,15 @@ function AddRelationshipForm({ entries, onAddProperty }: {
|
||||
onChange={e => setRelKey(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }}
|
||||
/>
|
||||
<NoteTargetInput entries={entries} value={relTarget} onChange={setRelTarget} onSubmit={submitForm} onCancel={resetForm} />
|
||||
<NoteTargetInput
|
||||
entries={entries}
|
||||
value={relTarget}
|
||||
onChange={setRelTarget}
|
||||
onSubmit={submitForm}
|
||||
onCancel={resetForm}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onSubmitWithCreate={handleCreateAndSubmit}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
|
||||
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
|
||||
@@ -234,12 +383,13 @@ function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterVa
|
||||
return updated.length === 1 ? updated[0] : updated
|
||||
}
|
||||
|
||||
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: {
|
||||
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty, onCreateAndOpenNote }: {
|
||||
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
}) {
|
||||
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
|
||||
|
||||
@@ -268,10 +418,11 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
|
||||
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
|
||||
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
|
||||
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
|
||||
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
|
||||
/>
|
||||
))}
|
||||
{onAddProperty
|
||||
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} />
|
||||
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
|
||||
: <button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Link existing</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,6 @@ function renderNoteList(props: {
|
||||
entries={props.entries}
|
||||
selection={props.selection}
|
||||
selectedNote={null}
|
||||
allContent={{}}
|
||||
onSelectNote={noop}
|
||||
onReplaceActiveTab={noop}
|
||||
onCreateNote={noop}
|
||||
|
||||
88
src/extensions/zoomCursorFix.test.ts
Normal file
88
src/extensions/zoomCursorFix.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { getDocumentZoom, adjustCoordsForZoom } from './zoomCursorFix'
|
||||
|
||||
function mockComputedZoom(value: string) {
|
||||
const real = window.getComputedStyle.bind(window)
|
||||
return vi.spyOn(window, 'getComputedStyle').mockImplementation((elt, pseudo) => {
|
||||
const style = real(elt, pseudo)
|
||||
if (elt === document.documentElement) {
|
||||
return new Proxy(style, {
|
||||
get(target, prop) {
|
||||
if (prop === 'zoom') return value
|
||||
const val = Reflect.get(target, prop)
|
||||
return typeof val === 'function' ? val.bind(target) : val
|
||||
},
|
||||
})
|
||||
}
|
||||
return style
|
||||
})
|
||||
}
|
||||
|
||||
describe('getDocumentZoom', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns 1 when no zoom is set', () => {
|
||||
expect(getDocumentZoom()).toBe(1)
|
||||
})
|
||||
|
||||
it('returns the zoom factor when computed style reports a decimal', () => {
|
||||
const spy = mockComputedZoom('1.5')
|
||||
expect(getDocumentZoom()).toBe(1.5)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns the zoom factor for sub-100% zoom', () => {
|
||||
const spy = mockComputedZoom('0.8')
|
||||
expect(getDocumentZoom()).toBe(0.8)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns 1 for zoom: normal', () => {
|
||||
const spy = mockComputedZoom('normal')
|
||||
expect(getDocumentZoom()).toBe(1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns 1 for empty/missing zoom value', () => {
|
||||
const spy = mockComputedZoom('')
|
||||
expect(getDocumentZoom()).toBe(1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('adjustCoordsForZoom', () => {
|
||||
it('returns coords unchanged when zoom is 1', () => {
|
||||
expect(adjustCoordsForZoom({ x: 200, y: 100 }, 1)).toEqual({ x: 200, y: 100 })
|
||||
})
|
||||
|
||||
it('divides coords by zoom factor for zoom > 1', () => {
|
||||
const result = adjustCoordsForZoom({ x: 300, y: 150 }, 1.5)
|
||||
expect(result.x).toBe(200)
|
||||
expect(result.y).toBe(100)
|
||||
})
|
||||
|
||||
it('divides coords by zoom factor for zoom < 1', () => {
|
||||
const result = adjustCoordsForZoom({ x: 160, y: 80 }, 0.8)
|
||||
expect(result.x).toBe(200)
|
||||
expect(result.y).toBe(100)
|
||||
})
|
||||
|
||||
it('handles common zoom levels correctly', () => {
|
||||
// 90% zoom
|
||||
const at90 = adjustCoordsForZoom({ x: 90, y: 90 }, 0.9)
|
||||
expect(at90.x).toBeCloseTo(100, 10)
|
||||
expect(at90.y).toBeCloseTo(100, 10)
|
||||
|
||||
// 110% zoom
|
||||
const at110 = adjustCoordsForZoom({ x: 110, y: 110 }, 1.1)
|
||||
expect(at110.x).toBeCloseTo(100, 10)
|
||||
expect(at110.y).toBeCloseTo(100, 10)
|
||||
|
||||
// 125% zoom
|
||||
const at125 = adjustCoordsForZoom({ x: 125, y: 125 }, 1.25)
|
||||
expect(at125.x).toBeCloseTo(100, 10)
|
||||
expect(at125.y).toBeCloseTo(100, 10)
|
||||
})
|
||||
})
|
||||
156
src/extensions/zoomCursorFix.ts
Normal file
156
src/extensions/zoomCursorFix.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { EditorView, ViewPlugin } from '@codemirror/view'
|
||||
|
||||
/**
|
||||
* Read the current CSS zoom factor from document.documentElement.
|
||||
* Returns 1 when no zoom is applied or the value is unparseable.
|
||||
*
|
||||
* Checks getComputedStyle first (real browsers return the decimal value),
|
||||
* then falls back to the inline style property (works in jsdom and test
|
||||
* environments where getComputedStyle doesn't report zoom).
|
||||
*/
|
||||
export function getDocumentZoom(): number {
|
||||
const computed = getComputedStyle(document.documentElement).zoom
|
||||
if (computed && computed !== 'normal') {
|
||||
const parsed = parseFloat(computed)
|
||||
if (parsed > 0 && isFinite(parsed)) return parsed
|
||||
}
|
||||
|
||||
const inline = document.documentElement.style.getPropertyValue('zoom')
|
||||
if (inline && inline !== 'normal') {
|
||||
let value = parseFloat(inline)
|
||||
if (inline.endsWith('%')) value /= 100
|
||||
if (value > 0 && isFinite(value)) return value
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert viewport-space coordinates to CSS-space coordinates by
|
||||
* dividing by the zoom factor. When CSS zoom is applied to the root
|
||||
* element, mouse event clientX/clientY are in viewport space, but
|
||||
* Range.getClientRects() (used by CodeMirror's posAtCoords) may return
|
||||
* values in CSS space. Dividing by zoom aligns them.
|
||||
*/
|
||||
export function adjustCoordsForZoom(
|
||||
coords: { x: number; y: number },
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
if (zoom === 1) return coords
|
||||
return { x: coords.x / zoom, y: coords.y / zoom }
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the browser's native caretRangeFromPoint API to find the document
|
||||
* position at viewport coordinates. This API correctly handles CSS zoom
|
||||
* because it operates in the browser's own coordinate system.
|
||||
*
|
||||
* Returns null if the API is unavailable or the position is outside the
|
||||
* editor's content area.
|
||||
*/
|
||||
function caretPosFromPoint(
|
||||
view: EditorView,
|
||||
x: number,
|
||||
y: number,
|
||||
): number | null {
|
||||
if (typeof document.caretRangeFromPoint !== 'function') return null
|
||||
|
||||
const range = document.caretRangeFromPoint(x, y)
|
||||
if (!range) return null
|
||||
|
||||
if (!view.contentDOM.contains(range.startContainer)) return null
|
||||
|
||||
try {
|
||||
return view.posAtDOM(range.startContainer, range.startOffset)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type Coords = { x: number; y: number }
|
||||
type PosAndSide = { pos: number; assoc: -1 | 1 }
|
||||
|
||||
/**
|
||||
* CodeMirror extension that fixes cursor positioning at non-100% CSS zoom.
|
||||
*
|
||||
* When CSS `zoom` is applied to document.documentElement, CodeMirror's
|
||||
* posAtCoords breaks because it compares mouse event coordinates (viewport
|
||||
* space) against Range.getClientRects() values (which may be in CSS space
|
||||
* under zoom). This extension overrides posAtCoords and posAndSideAtCoords
|
||||
* on the EditorView instance with zoom-aware versions that:
|
||||
*
|
||||
* 1. Use document.caretRangeFromPoint() — the browser's native, zoom-aware
|
||||
* coordinate-to-text API — to find the correct position.
|
||||
* 2. Fall back to the original method with coordinates divided by the zoom
|
||||
* factor if caretRangeFromPoint is unavailable or returns no result.
|
||||
*/
|
||||
export function zoomCursorFix() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyFn = (...args: any[]) => any
|
||||
|
||||
return ViewPlugin.define((view) => {
|
||||
const origPosAtCoords: AnyFn =
|
||||
Object.getPrototypeOf(view).posAtCoords
|
||||
const origPosAndSideAtCoords: AnyFn =
|
||||
Object.getPrototypeOf(view).posAndSideAtCoords
|
||||
|
||||
function zoomPosAtCoords(
|
||||
self: EditorView,
|
||||
coords: Coords,
|
||||
precise?: boolean,
|
||||
): number | null {
|
||||
const zoom = getDocumentZoom()
|
||||
if (zoom === 1) return origPosAtCoords.call(self, coords, precise)
|
||||
|
||||
const pos = caretPosFromPoint(self, coords.x, coords.y)
|
||||
if (pos !== null) return pos
|
||||
|
||||
const adjusted = adjustCoordsForZoom(coords, zoom)
|
||||
return origPosAtCoords.call(self, adjusted, precise)
|
||||
}
|
||||
|
||||
function zoomPosAndSideAtCoords(
|
||||
self: EditorView,
|
||||
coords: Coords,
|
||||
precise?: boolean,
|
||||
): PosAndSide | null {
|
||||
const zoom = getDocumentZoom()
|
||||
if (zoom === 1)
|
||||
return origPosAndSideAtCoords.call(self, coords, precise)
|
||||
|
||||
const pos = caretPosFromPoint(self, coords.x, coords.y)
|
||||
if (pos !== null) return { pos, assoc: 1 }
|
||||
|
||||
const adjusted = adjustCoordsForZoom(coords, zoom)
|
||||
return origPosAndSideAtCoords.call(self, adjusted, precise)
|
||||
}
|
||||
|
||||
// Override on the instance (shadows prototype methods)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(view as any).posAtCoords = function (
|
||||
this: EditorView,
|
||||
coords: Coords,
|
||||
precise?: boolean,
|
||||
) {
|
||||
return zoomPosAtCoords(this, coords, precise)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(view as any).posAndSideAtCoords = function (
|
||||
this: EditorView,
|
||||
coords: Coords,
|
||||
precise?: boolean,
|
||||
) {
|
||||
return zoomPosAndSideAtCoords(this, coords, precise)
|
||||
}
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
// Remove instance overrides, restoring prototype methods
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (view as any).posAtCoords
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (view as any).posAndSideAtCoords
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
192
src/hooks/useAIChat.test.ts
Normal file
192
src/hooks/useAIChat.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
|
||||
// Capture what streamClaudeChat receives
|
||||
const streamClaudeChatMock = vi.fn<
|
||||
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
|
||||
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
|
||||
>()
|
||||
|
||||
vi.mock('../utils/ai-chat', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
|
||||
return {
|
||||
...actual,
|
||||
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
|
||||
streamClaudeChatMock(...args)
|
||||
// Simulate async: emit text, then done
|
||||
const callbacks = args[3]
|
||||
setTimeout(() => {
|
||||
callbacks.onText('mock response')
|
||||
callbacks.onDone()
|
||||
}, 10)
|
||||
return Promise.resolve('')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { useAIChat } from './useAIChat'
|
||||
|
||||
beforeEach(() => {
|
||||
streamClaudeChatMock.mockClear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useAIChat', () => {
|
||||
it('sends first message as raw text without history', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
|
||||
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
|
||||
// First message: raw text, no history wrapping, no session_id
|
||||
expect(message).toBe('hello')
|
||||
expect(sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('embeds conversation history in second message', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// First exchange
|
||||
act(() => { result.current.sendMessage('What is 2+2?') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message — should include history from first exchange
|
||||
act(() => { result.current.sendMessage('What is that times 3?') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
||||
const [message] = streamClaudeChatMock.mock.calls[1]
|
||||
expect(message).toContain('<conversation_history>')
|
||||
expect(message).toContain('What is 2+2?')
|
||||
expect(message).toContain('mock response')
|
||||
expect(message).toContain('What is that times 3?')
|
||||
})
|
||||
|
||||
it('accumulates history across multiple exchanges', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Exchange 1
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 2
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 3
|
||||
act(() => { result.current.sendMessage('Q3') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
// First call: no history
|
||||
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
|
||||
// Second call: history from first exchange
|
||||
const secondMsg = streamClaudeChatMock.mock.calls[1][0]
|
||||
expect(secondMsg).toContain('Q1')
|
||||
expect(secondMsg).toContain('Q2')
|
||||
// Third call: history from both exchanges
|
||||
const thirdMsg = streamClaudeChatMock.mock.calls[2][0]
|
||||
expect(thirdMsg).toContain('Q1')
|
||||
expect(thirdMsg).toContain('Q2')
|
||||
expect(thirdMsg).toContain('Q3')
|
||||
})
|
||||
|
||||
it('never passes session_id (no --resume)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
|
||||
// All calls should have undefined session_id
|
||||
for (const call of streamClaudeChatMock.mock.calls) {
|
||||
expect(call[2]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets history after clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Build up some history
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Next message should have no history
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start') // raw text, no history wrapping
|
||||
expect(lastCall[2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes system prompt on every message when context notes exist', async () => {
|
||||
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
|
||||
|
||||
const { result } = renderHook(() => useAIChat(notes))
|
||||
|
||||
// First message
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message
|
||||
act(() => { result.current.sendMessage('follow up') })
|
||||
|
||||
// Both calls should have system prompt
|
||||
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
|
||||
expect(firstSystemPrompt).toBeTruthy()
|
||||
expect(firstSystemPrompt).toContain('Test Note')
|
||||
|
||||
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
|
||||
expect(secondSystemPrompt).toBeTruthy()
|
||||
expect(secondSystemPrompt).toContain('Test Note')
|
||||
})
|
||||
|
||||
it('retries with correct history (excludes retried exchange)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// First exchange
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
|
||||
// Retry the assistant response (index 1)
|
||||
act(() => { result.current.retryMessage(1) })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
// Should re-send the user message with no history (retrying first exchange)
|
||||
expect(lastCall[0]).toBe('hello')
|
||||
expect(lastCall[2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads latest messages from ref (not stale closure)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('msg1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Verify messages state is correct
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
expect(result.current.messages[0].content).toBe('msg1')
|
||||
expect(result.current.messages[1].content).toBe('mock response')
|
||||
|
||||
// Send second message — the ref should have the latest messages
|
||||
// even without depending on messages in useCallback deps
|
||||
act(() => { result.current.sendMessage('msg2') })
|
||||
|
||||
const secondCall = streamClaudeChatMock.mock.calls[1]
|
||||
const sentMessage = secondCall[0]
|
||||
// Must contain history from first exchange
|
||||
expect(sentMessage).toContain('[user]: msg1')
|
||||
expect(sentMessage).toContain('[assistant]: mock response')
|
||||
expect(sentMessage).toContain('[user]: msg2')
|
||||
})
|
||||
})
|
||||
@@ -1,82 +1,120 @@
|
||||
/**
|
||||
* Custom hook encapsulating AI chat state and message handling.
|
||||
* Uses Claude CLI subprocess via Tauri for streaming responses.
|
||||
*
|
||||
* Conversation continuity embeds prior exchanges in each prompt
|
||||
* (each CLI invocation is a fresh subprocess with no memory).
|
||||
* History is trimmed to MAX_HISTORY_TOKENS, dropping oldest first.
|
||||
*
|
||||
* Uses a ref (messagesRef) to read the latest messages in callbacks,
|
||||
* avoiding stale closure issues with React's useCallback memoization.
|
||||
*/
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type ChatMessage, nextMessageId,
|
||||
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
|
||||
buildSystemPrompt, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
|
||||
} from '../utils/ai-chat'
|
||||
|
||||
interface ChatStreamRefs {
|
||||
abortRef: React.RefObject<boolean>
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
|
||||
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
|
||||
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
/** Create stream callbacks that accumulate text and update React state. */
|
||||
function makeStreamCallbacks(
|
||||
refs: ChatStreamRefs,
|
||||
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
|
||||
let accumulated = ''
|
||||
const callbacks: ChatStreamCallbacks = {
|
||||
onText: (chunk) => {
|
||||
if (refs.abortRef.current) return
|
||||
accumulated += chunk
|
||||
refs.setStreamingContent(accumulated)
|
||||
},
|
||||
onError: (error) => {
|
||||
if (refs.abortRef.current) return
|
||||
refs.setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
|
||||
refs.setStreamingContent('')
|
||||
refs.setIsStreaming(false)
|
||||
},
|
||||
onDone: () => {
|
||||
if (refs.abortRef.current) return
|
||||
if (accumulated) {
|
||||
refs.setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
|
||||
}
|
||||
refs.setStreamingContent('')
|
||||
refs.setIsStreaming(false)
|
||||
},
|
||||
}
|
||||
return { callbacks, getAccumulated: () => accumulated }
|
||||
}
|
||||
|
||||
export function useAIChat(
|
||||
allContent: Record<string, string>,
|
||||
contextNotes: VaultEntry[],
|
||||
) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
const sessionIdRef = useRef<string | undefined>(undefined)
|
||||
const isStreamingRef = useRef(false)
|
||||
const messagesRef = useRef<ChatMessage[]>([])
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
if (!text.trim() || isStreaming) return
|
||||
// Keep refs in sync with state — runs after render, before next user interaction.
|
||||
useEffect(() => { messagesRef.current = messages }, [messages])
|
||||
useEffect(() => { isStreamingRef.current = isStreaming }, [isStreaming])
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: text.trim(), id: nextMessageId() }
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
/** Internal: send text, reading history from the messages ref. */
|
||||
const doSend = useCallback((text: string, historyOverride?: ChatMessage[]) => {
|
||||
if (!text.trim() || isStreamingRef.current) return
|
||||
|
||||
const history = historyOverride ?? messagesRef.current
|
||||
|
||||
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
|
||||
setIsStreaming(true)
|
||||
setStreamingContent('')
|
||||
abortRef.current = false
|
||||
|
||||
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
|
||||
let accumulated = ''
|
||||
// Always include system prompt (each request is a fresh subprocess).
|
||||
const systemPrompt = buildSystemPrompt(contextNotes).prompt || undefined
|
||||
|
||||
streamClaudeChat(text.trim(), systemPrompt || undefined, sessionIdRef.current, {
|
||||
onInit: (sid) => { sessionIdRef.current = sid },
|
||||
// Embed conversation history in the prompt for continuity.
|
||||
const trimmedHistory = trimHistory(history, MAX_HISTORY_TOKENS)
|
||||
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
|
||||
|
||||
onText: (chunk) => {
|
||||
if (abortRef.current) return
|
||||
accumulated += chunk
|
||||
setStreamingContent(accumulated)
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
if (abortRef.current) return
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
},
|
||||
|
||||
onDone: () => {
|
||||
if (abortRef.current) return
|
||||
if (accumulated) {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
|
||||
}
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
},
|
||||
}).then(sid => {
|
||||
if (sid) sessionIdRef.current = sid
|
||||
const { callbacks } = makeStreamCallbacks({
|
||||
abortRef, setMessages, setStreamingContent, setIsStreaming,
|
||||
})
|
||||
}, [isStreaming, allContent, contextNotes])
|
||||
|
||||
streamClaudeChat(formattedMessage, systemPrompt, undefined, callbacks)
|
||||
.catch(() => { /* errors forwarded via onError */ })
|
||||
}, [contextNotes])
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
doSend(text)
|
||||
}, [doSend])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
setMessages([])
|
||||
setIsStreaming(false)
|
||||
setStreamingContent('')
|
||||
sessionIdRef.current = undefined
|
||||
}, [])
|
||||
|
||||
const retryMessage = useCallback((msgIndex: number) => {
|
||||
const currentMessages = messagesRef.current
|
||||
const userMsgIndex = msgIndex - 1
|
||||
if (userMsgIndex < 0) return
|
||||
const userMsg = messages[userMsgIndex]
|
||||
const userMsg = currentMessages[userMsgIndex]
|
||||
if (userMsg.role !== 'user') return
|
||||
|
||||
setMessages(prev => prev.slice(0, msgIndex))
|
||||
sendMessage(userMsg.content)
|
||||
}, [messages, sendMessage])
|
||||
const historyForRetry = currentMessages.slice(0, userMsgIndex)
|
||||
setMessages(prev => prev.slice(0, userMsgIndex))
|
||||
doSend(userMsg.content, historyForRetry)
|
||||
}, [doSend])
|
||||
|
||||
return { messages, isStreaming, streamingContent, sendMessage, clearConversation, retryMessage }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user