Compare commits
65 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 |
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/
|
||||
@@ -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
|
||||
|
||||
|
||||
325
CLAUDE.md
325
CLAUDE.md
@@ -1,276 +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
|
||||
```
|
||||
|
||||
**You must write a new Playwright test for this task** in `tests/smoke/<slug>.spec.ts` that covers every acceptance criterion. Do not rely only on existing smoke tests — they test the app in general, not your specific feature.
|
||||
**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`.
|
||||
|
||||
**What to cover in your Playwright test:**
|
||||
- Every acceptance criterion from the task spec → one `test()` block per criterion
|
||||
- Every command palette entry → 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
|
||||
- **The happy path end-to-end**: simulate exactly what a user would do to use this feature
|
||||
### Phase 2: Native QA (Brian does this after push)
|
||||
|
||||
**The test must fail before your fix and pass after.** If you can't write a test that demonstrates the bug is fixed, your test doesn't cover the right thing.
|
||||
|
||||
**Playwright is non-negotiable even if unit tests pass.** Unit tests verify code; Playwright verifies the user experience in the real browser. Both are required.
|
||||
|
||||
> **⚠️ Browser dev server limits**: the dev server uses mock Tauri handlers (`src/mock-tauri.ts`) — file system operations, git commands, and native dialogs are mocked. **If your task touches the filesystem, AI context pipeline, MCP server, git integration, or any Tauri command that reads/writes real files, Playwright alone is not enough.** You must also do Phase 1b.
|
||||
|
||||
### Phase 1b: Tauri dev QA (you do this for filesystem/native tasks)
|
||||
|
||||
If your task touches **any of the following**, you must also test with `pnpm tauri dev` against the real vault before firing done:
|
||||
- File read/write (notes, cache, vault config)
|
||||
- AI chat context (what the AI actually receives as input)
|
||||
- MCP server / subprocess communication
|
||||
- Git integration (commit, push, history, diff)
|
||||
- Native dialogs or OS-level features
|
||||
Brian installs the release build and runs keyboard-only QA. Phase 1 must pass first or the task goes to To Rework.
|
||||
|
||||
Fire done signal only after Phase 1 passes:
|
||||
```bash
|
||||
# Start Tauri dev app from your worktree
|
||||
pnpm tauri dev --port <N> &
|
||||
sleep 10 # wait for Tauri + Vite to boot
|
||||
|
||||
# Then test using osascript keyboard events (NO mouse/cliclick)
|
||||
# Example:
|
||||
osascript -e 'tell application "laputa" to activate'
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
# ...simulate the full user flow from the acceptance criteria
|
||||
```
|
||||
|
||||
**What to verify in Phase 1b:**
|
||||
- Open the feature on a real note in `~/Laputa` (not the demo vault)
|
||||
- Walk through every acceptance criterion step by step using keyboard only
|
||||
- Verify file changes with `git -C ~/Laputa diff` if the task writes files
|
||||
- Verify AI responses actually contain note content (not empty) if the task touches AI context
|
||||
|
||||
**⚠️ Claude Code runs headless — you cannot see the screen.** Use `screencapture /tmp/qa-check.png` and then read/describe what you see if you need visual verification. Or rely on DOM state checks via osascript accessibility API.
|
||||
|
||||
### 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.
|
||||
|
||||
**⚠️ Phase 1 is YOUR quality gate, not a formality.**
|
||||
Brian's Phase 2 QA is a *reinforcement* check, not the primary gate. If Brian finds a bug in Phase 2 that you could have caught in Phase 1, that is a Phase 1 failure — not a Phase 2 discovery. Before firing the done signal, ask yourself: "Did I actually verify, in Playwright, that the feature works end-to-end exactly as the spec describes?" If the answer is "I ran the smoke tests and they passed", that is not enough. You must run your task-specific test and verify the acceptance criteria one by one.
|
||||
|
||||
**⚠️ Test in a clean environment when the feature depends on state.**
|
||||
If a feature involves indexing, fresh installs, first-time setup, or anything that only runs once:
|
||||
- **Do not test in the existing dev vault** — it already has the state you're trying to test.
|
||||
- **Create a new empty vault** for the test: Cmd+K → "New Vault" (or equivalent), pick a temp folder like `/tmp/test-vault-<slug>`, then test the full first-time flow from scratch.
|
||||
- This applies to: search indexing, vault init, getting-started setup, any "on first open" logic.
|
||||
- If you can't reproduce the fresh-install scenario locally, the feature is untestable → do not fire done.
|
||||
|
||||
Fire done signal only after QA passes:
|
||||
```bash
|
||||
rm -f /tmp/laputa-qa.lock
|
||||
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 (mandatory — see rule below)
|
||||
- **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:`
|
||||
|
||||
## ⛔ DOCS — Keep docs/ in sync with code (mandatory)
|
||||
## TDD (mandatory)
|
||||
|
||||
After any significant feature change, update the relevant `docs/` files **in the same commit**:
|
||||
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.
|
||||
|
||||
- **`docs/ARCHITECTURE.md`** — stack, system overview, component structure, Tauri commands, data flow, backend modules
|
||||
- **`docs/ABSTRACTIONS.md`** — domain models, VaultEntry fields, entity types, key abstractions, integration patterns
|
||||
- **`docs/GETTING-STARTED.md`** — directory structure, key files, common tasks, test commands, onboarding
|
||||
## ⛔ Docs — Keep docs/ in sync
|
||||
|
||||
**What counts as "significant":**
|
||||
- Adding a new Tauri command or backend module
|
||||
- Adding a new major component, hook, or feature (not a bugfix)
|
||||
- Changing the data model (VaultEntry fields, new types, new config files)
|
||||
- Adding a new integration (API, service, transport)
|
||||
- Changing the architecture (new panels, new state management, new build steps)
|
||||
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.
|
||||
|
||||
**How to update:**
|
||||
1. Read the relevant doc section before making changes
|
||||
2. After your code changes, update the doc to reflect the new state
|
||||
3. Commit doc changes together with the code — not in a separate follow-up commit
|
||||
## Design File (UI tasks)
|
||||
|
||||
If unsure whether a change is "significant", err on the side of updating. Stale docs are worse than slightly verbose docs.
|
||||
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`.
|
||||
|
||||
## TDD — Red/Green/Refactor (mandatory)
|
||||
## Vault Retrocompatibility
|
||||
|
||||
**Always use test-driven development.** No production code without a failing test first.
|
||||
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.
|
||||
|
||||
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.
|
||||
## Keyboard-First + Menu Bar (mandatory)
|
||||
|
||||
**Why this matters:**
|
||||
- Forces you to think about behavior before implementation
|
||||
- Produces only code that's actually needed (no speculative abstractions)
|
||||
- Tests written first are always behavioral and structure-insensitive by construction
|
||||
- Tiny cycles = fast feedback, smaller diffs, easier to review
|
||||
|
||||
**For bug fixes:**
|
||||
1. Write a failing test that reproduces the bug (this is the regression test)
|
||||
2. Fix the bug until the test passes
|
||||
3. Commit both together: `fix: [bug] — regression test added`
|
||||
|
||||
**For Rust:**
|
||||
```bash
|
||||
cargo watch -x test # run tests on every save
|
||||
```
|
||||
|
||||
**For frontend:**
|
||||
```bash
|
||||
pnpm test --watch # run tests on every save
|
||||
```
|
||||
|
||||
**When to deviate:** Pure UI layout/styling work with no logic is the only exception. Everything else — hooks, utilities, Rust commands, state management — must be TDD.
|
||||
|
||||
## Testing (quality bar)
|
||||
|
||||
- Unit tests must cover real business logic, not "component renders"
|
||||
- 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
|
||||
|
||||
@@ -278,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
design/trash-management.pen
Normal file
1
design/trash-management.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -32,7 +32,56 @@ All data lives in markdown files with YAML frontmatter. There is no database —
|
||||
|
||||
### VaultEntry
|
||||
|
||||
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.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
|
||||
@@ -315,25 +364,31 @@ const WikiLink = createReactInlineContentSpec(
|
||||
|
||||
### Markdown-to-BlockNote Pipeline
|
||||
|
||||
```
|
||||
Raw markdown
|
||||
→ splitFrontmatter() → [yaml, body]
|
||||
→ preProcessWikilinks(body) → replaces [[target]] with Unicode placeholder tokens
|
||||
→ editor.tryParseMarkdownToBlocks() → BlockNote block tree
|
||||
→ injectWikilinks(blocks) → walks tree, replaces placeholders with wikilink inline content nodes
|
||||
→ editor.replaceBlocks()
|
||||
```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"]
|
||||
|
||||
style A fill:#f8f9fa,stroke:#6c757d,color:#000
|
||||
style F fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
|
||||
> Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
```
|
||||
BlockNote blocks
|
||||
→ editor.blocksToMarkdownLossy()
|
||||
→ postProcessWikilinks() → restore [[target]] syntax from wikilink nodes
|
||||
→ prepend frontmatter yaml
|
||||
→ invoke('save_note_content', { path, content })
|
||||
```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
|
||||
@@ -416,6 +471,10 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
|
||||
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
|
||||
|
||||
@@ -31,6 +31,22 @@ Vault data exists in three forms simultaneously:
|
||||
|
||||
These must never diverge permanently. If they do, the filesystem wins and the cache/state are rebuilt.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FS["🗂️ Filesystem\n.md files on disk\n(source of truth)"]
|
||||
Cache["⚡ Cache\n~/.laputa/cache/\n(fast startup index)"]
|
||||
RS["⚛️ React State\nVaultEntry[]\n(in-memory session)"]
|
||||
|
||||
FS -->|"scan_vault_cached()"| Cache
|
||||
Cache -->|"useVaultLoader on load"| RS
|
||||
FS -->|"reload_vault (full rescan)"| RS
|
||||
RS -.->|"write via Tauri IPC first"| FS
|
||||
|
||||
style FS fill:#d4edda,stroke:#28a745,color:#000
|
||||
style Cache fill:#fff3cd,stroke:#ffc107,color:#000
|
||||
style RS fill:#cce5ff,stroke:#004085,color:#000
|
||||
```
|
||||
|
||||
#### Ownership rules
|
||||
|
||||
| Layer | Owner | Writes to | Reads from |
|
||||
@@ -70,42 +86,52 @@ These must never diverge permanently. If they do, the filesystem wins and the ca
|
||||
|
||||
## System Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Tauri v2 Window │
|
||||
│ │
|
||||
│ ┌──────────────────── React Frontend ────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ App.tsx (orchestrator) │ │
|
||||
│ │ ├── WelcomeScreen (onboarding / vault-missing) │ │
|
||||
│ │ ├── Sidebar (navigation + filters + types) │ │
|
||||
│ │ ├── NoteList / PulseView (filtered list / activity) │ │
|
||||
│ │ ├── Editor (BlockNote + tabs + diff + raw) │ │
|
||||
│ │ │ ├── Inspector (metadata + relationships) │ │
|
||||
│ │ │ ├── AIChatPanel (API-based chat) │ │
|
||||
│ │ │ └── AiPanel (Claude CLI agent + tools) │ │
|
||||
│ │ ├── SearchPanel (keyword/semantic/hybrid search) │ │
|
||||
│ │ ├── SettingsPanel (API keys, GitHub, zoom, theme) │ │
|
||||
│ │ ├── StatusBar (vault picker + sync + version) │ │
|
||||
│ │ ├── CommandPalette (Cmd+K fuzzy command launcher) │ │
|
||||
│ │ └── Modals (CreateNote, CreateType, Commit, GitHub) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────┬──────────┬──────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ Tauri IPC│ Vite Proxy / WS │
|
||||
│ ┌──────────────▼────┐ ┌──▼────────────────────────────────┐ │
|
||||
│ │ Rust Backend │ │ External Services │ │
|
||||
│ │ lib.rs → 62 cmds │ │ Anthropic API (Claude chat) │ │
|
||||
│ │ vault/ │ │ Claude CLI (agent subprocess) │ │
|
||||
│ │ frontmatter/ │ │ MCP Server (ws://9710, 9711) │ │
|
||||
│ │ git/ │ │ qmd (search/indexing engine) │ │
|
||||
│ │ github/ │ │ GitHub API (OAuth, repos, clone) │ │
|
||||
│ │ theme/ │ │ │ │
|
||||
│ │ search.rs │ └───────────────────────────────────┘ │
|
||||
│ │ indexing.rs │ │
|
||||
│ │ claude_cli.rs │ │
|
||||
│ └───────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph TW["Tauri v2 Window"]
|
||||
subgraph FE["React Frontend"]
|
||||
App["App.tsx (orchestrator)"]
|
||||
WS["WelcomeScreen\n(onboarding)"]
|
||||
SB["Sidebar\n(navigation + filters + types)"]
|
||||
NL["NoteList / PulseView\n(filtered list / activity)"]
|
||||
ED["Editor\n(BlockNote + tabs + diff + raw)"]
|
||||
IN["Inspector\n(metadata + relationships)"]
|
||||
AIC["AIChatPanel\n(API-based chat)"]
|
||||
AIP["AiPanel\n(Claude CLI agent + tools)"]
|
||||
SP["SearchPanel\n(keyword/semantic/hybrid)"]
|
||||
ST["StatusBar\n(vault picker + sync + version)"]
|
||||
CP["CommandPalette\n(Cmd+K launcher)"]
|
||||
|
||||
App --> WS & SB & NL & ED & SP & ST & CP
|
||||
ED --> IN & AIC & AIP
|
||||
end
|
||||
|
||||
subgraph RB["Rust Backend"]
|
||||
LIB["lib.rs → 64 Tauri commands"]
|
||||
VAULT["vault/"]
|
||||
FM["frontmatter/"]
|
||||
GIT["git/"]
|
||||
GH["github/"]
|
||||
THEME["theme/"]
|
||||
SEARCH["search.rs + indexing.rs"]
|
||||
CLI["claude_cli.rs"]
|
||||
end
|
||||
|
||||
subgraph EXT["External Services"]
|
||||
ANTH["Anthropic API\n(Claude chat)"]
|
||||
CCLI["Claude CLI\n(agent subprocess)"]
|
||||
MCP["MCP Server\n(ws://9710, 9711)"]
|
||||
QMD["qmd\n(search engine)"]
|
||||
GHAPI["GitHub API\n(OAuth, repos, clone)"]
|
||||
end
|
||||
|
||||
FE -->|"Tauri IPC"| RB
|
||||
FE -->|"Vite Proxy / WS"| EXT
|
||||
end
|
||||
|
||||
style FE fill:#e8f4fd,stroke:#2196f3,color:#000
|
||||
style RB fill:#fff8e1,stroke:#ff9800,color:#000
|
||||
style EXT fill:#f3e5f5,stroke:#9c27b0,color:#000
|
||||
```
|
||||
|
||||
## Four-Panel Layout
|
||||
@@ -160,21 +186,38 @@ Full agent mode — spawns Claude CLI as a subprocess with tool access and MCP v
|
||||
|
||||
#### Agent Event Flow
|
||||
|
||||
```
|
||||
User sends message in AiPanel
|
||||
→ useAiAgent.sendMessage(text, references)
|
||||
→ buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
→ invoke('stream_claude_agent', { message, systemPrompt, vaultPath })
|
||||
→ Rust spawns: claude -p <msg> --output-format stream-json --mcp-config <json>
|
||||
→ NDJSON lines parsed into ClaudeStreamEvent variants:
|
||||
Init, TextDelta, ThinkingDelta, ToolStart, ToolDone, Result, Error, Done
|
||||
→ Events emitted via Tauri: app_handle.emit("claude-agent-stream", &event)
|
||||
→ Frontend listener routes events:
|
||||
onText → accumulate response (revealed on Done)
|
||||
onThinking → show reasoning block (collapsed on first text)
|
||||
onToolStart → add AiActionCard with spinner
|
||||
onToolDone → update card with output
|
||||
onDone → reveal full response, detect file operations
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User (AiPanel)
|
||||
participant FE as useAiAgent (Frontend)
|
||||
participant R as claude_cli.rs (Rust)
|
||||
participant C as Claude CLI
|
||||
participant V as Vault (MCP)
|
||||
|
||||
U->>FE: sendMessage(text, references)
|
||||
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
FE->>R: invoke('stream_claude_agent', {message, systemPrompt, vaultPath})
|
||||
R->>C: spawn claude -p <msg> --output-format stream-json --mcp-config <json>
|
||||
|
||||
loop NDJSON stream
|
||||
C-->>R: Init | TextDelta | ThinkingDelta | ToolStart | ToolDone | Result | Done
|
||||
R-->>FE: emit("claude-agent-stream", event)
|
||||
alt TextDelta
|
||||
FE->>FE: accumulate response (revealed on Done)
|
||||
else ThinkingDelta
|
||||
FE->>FE: show reasoning block (collapses on first text)
|
||||
else ToolStart
|
||||
FE->>FE: add AiActionCard with spinner
|
||||
else ToolDone
|
||||
FE->>FE: update card with output
|
||||
else Done
|
||||
FE->>FE: reveal full response
|
||||
FE->>FE: detect file operations → reload vault if needed
|
||||
end
|
||||
end
|
||||
|
||||
C->>V: MCP tool calls (search_notes, read_note, edit_note…)
|
||||
V-->>C: tool results
|
||||
```
|
||||
|
||||
#### File Operation Detection
|
||||
@@ -251,36 +294,31 @@ Registration is non-destructive (additive, preserves other servers) and uses `up
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ MCP Server (Node.js) │
|
||||
│ │
|
||||
│ index.js ─── stdio transport ──→ Claude Code │
|
||||
│ │ Cursor │
|
||||
│ ├── vault.js (9 vault operations) │
|
||||
│ │ ├── findMarkdownFiles ├── deleteNote │
|
||||
│ │ ├── readNote ├── linkNotes │
|
||||
│ │ ├── createNote ├── listNotes │
|
||||
│ │ ├── searchNotes ├── vaultContext │
|
||||
│ │ ├── appendToNote │
|
||||
│ │ └── editNoteFrontmatter │
|
||||
│ │ │
|
||||
│ └── ws-bridge.js │
|
||||
│ ├── port 9710: tool bridge ←→ AI clients │
|
||||
│ └── port 9711: UI bridge ←→ Frontend │
|
||||
│ │
|
||||
│ Spawned by Tauri (mcp.rs) on app startup │
|
||||
│ Auto-registered in ~/.claude/mcp.json │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph MCP["MCP Server (Node.js) — spawned by Tauri on startup"]
|
||||
IDX["index.js"]
|
||||
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
|
||||
WSB["ws-bridge.js"]
|
||||
|
||||
IDX -->|"stdio transport"| STDIO["Claude Code / Cursor"]
|
||||
IDX --> VAULT
|
||||
IDX --> WSB
|
||||
WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"]
|
||||
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
|
||||
end
|
||||
|
||||
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
|
||||
TAURI -->|"auto-register"| CFG["~/.claude/mcp.json\n~/.cursor/mcp.json"]
|
||||
```
|
||||
|
||||
### WebSocket Bridge
|
||||
|
||||
The WebSocket bridge enables real-time vault operations from both the frontend and external AI clients:
|
||||
|
||||
```
|
||||
Frontend (useMcpBridge) ←→ ws://localhost:9710 ←→ ws-bridge.js ←→ vault.js
|
||||
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions (useAiActivity)
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FE["Frontend\n(useMcpBridge)"] <-->|"ws://localhost:9710"| WSB["ws-bridge.js"]
|
||||
WSB <--> VAULT["vault.js"]
|
||||
STDIO["MCP stdio tools"] <-->|"ws://localhost:9711"| FE2["Frontend UI actions\n(useAiActivity)"]
|
||||
```
|
||||
|
||||
**Tool bridge protocol** (port 9710):
|
||||
@@ -317,16 +355,28 @@ Search uses the external `qmd` binary (semantic search engine) with three modes:
|
||||
|
||||
### Indexing Flow
|
||||
|
||||
```
|
||||
Vault opened
|
||||
→ check_index_status() → parse qmd status output
|
||||
→ if stale or missing:
|
||||
→ start_indexing() (two phases):
|
||||
Phase 1 (Scanning): qmd update — scan all .md files
|
||||
Phase 2 (Embedding): qmd embed — generate vector embeddings
|
||||
→ Progress streamed via Tauri "indexing-progress" event
|
||||
→ Metadata saved to .laputa-index.json (last_indexed_commit, timestamp)
|
||||
→ run_incremental_update() for subsequent changes
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([Vault opened]) --> B[check_index_status]
|
||||
B --> C{Index status?}
|
||||
C -->|Fresh| D[run_incremental_update\ngit diff since last commit]
|
||||
C -->|Stale / Missing| E
|
||||
|
||||
subgraph E[Full Indexing — start_indexing]
|
||||
E1["Phase 1: qmd update\n(scan all .md files)"]
|
||||
E2["Phase 2: qmd embed\n(generate vector embeddings)"]
|
||||
E1 --> E2
|
||||
end
|
||||
|
||||
E --> F[Save .laputa-index.json\nlast_indexed_commit + timestamp]
|
||||
D --> G([Search ready])
|
||||
F --> G
|
||||
|
||||
E2 -.->|failure is non-fatal| G
|
||||
G --> H{Search mode}
|
||||
H -->|keyword| I[qmd search]
|
||||
H -->|semantic| J[qmd vsearch]
|
||||
H -->|hybrid| K[qmd query]
|
||||
```
|
||||
|
||||
Embedding failure is non-fatal — keyword search still works.
|
||||
@@ -349,9 +399,19 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
|
||||
|
||||
### Three Cache Strategies
|
||||
|
||||
1. **Same Commit (Cache Hit)**: Git HEAD matches cached hash → only re-parse uncommitted changed files via `git status --porcelain`
|
||||
2. **Different Commit (Incremental Update)**: Uses `git diff <old>..<new> --name-only` to find changed files + uncommitted changes → selective re-parse
|
||||
3. **No Cache / Corrupt Cache (Full Scan)**: Recursive `walkdir` of all `.md` files → full parse
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A([scan_vault_cached]) --> B{Cache exists\nand valid?}
|
||||
B -->|No / Corrupt| C["🔴 Full Scan\nwalkdir all .md files\n→ full parse"]
|
||||
B -->|Yes| D{Git HEAD\nmatches cache?}
|
||||
D -->|Same commit| E["🟢 Cache Hit\ngit status --porcelain\n→ re-parse only uncommitted changes"]
|
||||
D -->|Different commit| F["🟡 Incremental Update\ngit diff old..new --name-only\n→ selective re-parse of changed files"]
|
||||
|
||||
C --> G[Write cache atomically\n.tmp → rename]
|
||||
E --> G
|
||||
F --> G
|
||||
G --> H([VaultEntry list ready])
|
||||
```
|
||||
|
||||
## Theme System
|
||||
|
||||
@@ -432,56 +492,69 @@ Backend: `get_vault_pulse` Tauri command parses `git log` with `--name-status`.
|
||||
|
||||
### Startup Sequence
|
||||
|
||||
```
|
||||
1. Tauri setup:
|
||||
a. run_startup_tasks() → purge trash, migrate frontmatter, seed themes, migrate AGENTS.md, seed config files, register MCP
|
||||
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
|
||||
2. App mounts
|
||||
3. useOnboarding checks vault exists → WelcomeScreen if not
|
||||
4. useVaultLoader fires:
|
||||
a. invoke('list_vault', { path }) → scan_vault_cached() → VaultEntry[]
|
||||
b. Load modified files via invoke('get_modified_files')
|
||||
c. useMcpStatus → register MCP if needed
|
||||
d. useThemeManager → load and apply active theme
|
||||
e. useIndexing → check index status, trigger incremental update if needed
|
||||
5. User clicks note in NoteList
|
||||
6. useNoteActions.handleSelectNote:
|
||||
a. invoke('get_note_content') → raw markdown
|
||||
b. Add tab { entry, content } to tabs state
|
||||
c. Set activeTabPath
|
||||
7. Editor renders BlockNoteTab:
|
||||
a. splitFrontmatter(content) → [yaml, body]
|
||||
b. preProcessWikilinks(body) → replaces [[target]] with tokens
|
||||
c. editor.tryParseMarkdownToBlocks(preprocessed)
|
||||
d. injectWikilinks(blocks) → replaces tokens with wikilink nodes
|
||||
e. editor.replaceBlocks()
|
||||
8. Inspector renders frontmatter parsed from content
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as Tauri (Rust)
|
||||
participant A as App.tsx
|
||||
participant VL as useVaultLoader
|
||||
participant MCP as MCP Server
|
||||
participant U as User
|
||||
|
||||
T->>T: run_startup_tasks()<br/>(purge trash, seed themes, register MCP)
|
||||
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
|
||||
T->>A: App mounts
|
||||
|
||||
A->>A: useOnboarding — vault exists?
|
||||
alt Vault missing
|
||||
A-->>U: WelcomeScreen
|
||||
else Vault found
|
||||
A->>VL: useVaultLoader fires
|
||||
VL->>T: invoke('list_vault') → scan_vault_cached()
|
||||
T-->>VL: VaultEntry[]
|
||||
VL->>T: invoke('get_modified_files')
|
||||
VL->>T: useMcpStatus — register if needed
|
||||
VL->>T: useThemeManager — load active theme
|
||||
VL->>T: useIndexing — incremental update if stale
|
||||
VL-->>A: entries ready
|
||||
end
|
||||
|
||||
U->>A: clicks note in NoteList
|
||||
A->>T: invoke('get_note_content')
|
||||
T-->>A: raw markdown
|
||||
A->>A: splitFrontmatter → [yaml, body]
|
||||
A->>A: preProcessWikilinks(body)
|
||||
A->>A: tryParseMarkdownToBlocks()
|
||||
A->>A: injectWikilinks(blocks)
|
||||
A-->>U: Editor renders note
|
||||
```
|
||||
|
||||
### Auto-Save Flow
|
||||
|
||||
```
|
||||
Editor content changes
|
||||
→ useEditorSave detects change (debounced)
|
||||
→ serialize BlockNote blocks → markdown
|
||||
→ postProcessWikilinks → restore [[target]] syntax
|
||||
→ invoke('save_note_content', { path, content })
|
||||
→ Update tab status indicator
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["✏️ Editor content changes"] --> B["useEditorSave\n(debounced)"]
|
||||
B --> C["blocksToMarkdownLossy()"]
|
||||
C --> D["postProcessWikilinks()\n→ restore [[target]] syntax"]
|
||||
D --> E["invoke('save_note_content')"]
|
||||
E --> F["💾 Disk write"]
|
||||
F --> G["Update tab status indicator"]
|
||||
```
|
||||
|
||||
### Git Sync Flow
|
||||
|
||||
```
|
||||
useAutoSync (configurable interval, default from settings):
|
||||
→ invoke('git_pull') → GitPullResult
|
||||
→ if conflicts → ConflictResolverModal
|
||||
→ if fast-forward → reload vault
|
||||
→ invoke('git_push') → GitPushResult
|
||||
```mermaid
|
||||
flowchart TD
|
||||
AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"]
|
||||
PULL --> PC{Result?}
|
||||
PC -->|Conflicts| CM["ConflictResolverModal"]
|
||||
PC -->|Fast-forward| RV["reload vault"]
|
||||
PC -->|Up to date| DONE["idle"]
|
||||
|
||||
Manual commit:
|
||||
→ CommitDialog → invoke('git_commit', { message })
|
||||
→ invoke('git_push')
|
||||
→ Reload modified files
|
||||
AS --> PUSH["invoke('git_push')"]
|
||||
|
||||
MAN["Manual commit\n(CommitDialog)"] --> GC["invoke('git_commit', message)"]
|
||||
GC --> GP["invoke('git_push')"]
|
||||
GP --> RM["Reload modified files"]
|
||||
```
|
||||
|
||||
## Vault Module Structure
|
||||
@@ -520,7 +593,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
|
||||
## Tauri IPC Commands (62 total)
|
||||
## Tauri IPC Commands (64 total)
|
||||
|
||||
### Vault Operations
|
||||
|
||||
@@ -533,6 +606,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `rename_note` | Rename note + update cross-vault wikilinks |
|
||||
| `batch_archive_notes` | Archive multiple notes |
|
||||
| `batch_trash_notes` | Trash multiple notes |
|
||||
| `batch_delete_notes` | Permanently delete notes from disk |
|
||||
| `empty_trash` | Permanently delete all trashed notes from disk |
|
||||
| `purge_trash` | Delete notes trashed >30 days ago |
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
@@ -649,7 +724,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
|
||||
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
|
||||
| `useNoteActions` | `tabs`, `activeTabPath` | Open tabs and note operations |
|
||||
| `useTabManagement` | Tab ordering, pinning, swapping | Tab lifecycle |
|
||||
| `useTabManagement` | Tab ordering, pinning, swapping, closed-tab history | Tab lifecycle |
|
||||
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
|
||||
| `useThemeManager` | `themes`, `activeThemeId`, `isDark` | Theme state |
|
||||
| `useAIChat` | `messages`, `isStreaming` | AI chat conversation |
|
||||
@@ -670,6 +745,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
| Cmd+N | Create new note |
|
||||
| Cmd+S | Save current note |
|
||||
| Cmd+W | Close active tab |
|
||||
| Cmd+Shift+T | Reopen last closed tab (LIFO history, up to 20) |
|
||||
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
|
||||
| Cmd+1–9 | Switch to tab N |
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward |
|
||||
|
||||
@@ -182,35 +182,6 @@ Broader audiences will follow as the onboarding experience matures and the conve
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
A living snapshot of what's built. Updated as features ship.
|
||||
|
||||
### ✅ Working today
|
||||
|
||||
- BlockNote editor with Markdown files on disk; Cmd+S save; dirty state indicator
|
||||
- 4-panel layout: sidebar / note list / editor / inspector
|
||||
- Tabs with drag-to-reorder; quick open (Cmd+P); virtual list (handles 9000+ notes)
|
||||
- `type:` as canonical frontmatter field; inspector with editable properties
|
||||
- Bidirectional relationships; editable relation chips; wikilink autocomplete (`[[`)
|
||||
- Sidebar sections with custom icons and colors
|
||||
- Git integration: commit & push, version history per note, dirty state tracking
|
||||
- Dynamic vault picker; create or clone vaults from GitHub
|
||||
- GitHub OAuth (device flow); AI chat panel; Claude CLI agent panel
|
||||
- Auto-updater; universal macOS binary; CI with coverage gates
|
||||
|
||||
### 🚧 Ahead (consolidation sprint → features)
|
||||
|
||||
**Consolidation sprint (current):**
|
||||
Fixing architectural foundations before building further — cache model, type field canonicalization, allContent removal, hardcoded paths.
|
||||
|
||||
**Next features:**
|
||||
Inbox section, semantic properties (status chips, progress indicators), default relationships in properties panel, workspace filter, mobile apps (iPhone for capture, iPad as desktop mirror).
|
||||
|
||||
*For the full roadmap, see [ROADMAP.md](./ROADMAP.md).*
|
||||
|
||||
---
|
||||
|
||||
## Design principles
|
||||
|
||||
1. **Opinionated but not rigid** — ship the method and the defaults; allow customization where it matters
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
@@ -84,10 +84,11 @@ 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]
|
||||
@@ -113,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);
|
||||
|
||||
@@ -143,6 +143,8 @@ pub fn run() {
|
||||
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,
|
||||
|
||||
@@ -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";
|
||||
@@ -38,6 +39,7 @@ 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";
|
||||
@@ -61,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,
|
||||
@@ -82,6 +85,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
VAULT_RESTORE_GETTING_STARTED,
|
||||
@@ -165,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)
|
||||
@@ -174,6 +182,7 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.separator()
|
||||
.item(&save)
|
||||
.item(&close_tab)
|
||||
.item(&reopen_closed_tab)
|
||||
.build()?)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -471,6 +484,7 @@ mod tests {
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
VAULT_RESTORE_GETTING_STARTED,
|
||||
|
||||
@@ -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,185 +159,286 @@ 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\
|
||||
type: 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\
|
||||
type: 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\
|
||||
type: 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\
|
||||
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 {
|
||||
@@ -870,4 +870,80 @@ mod tests {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,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}"))?;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ pub use getting_started::{create_getting_started_vault, default_vault_path, vaul
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{move_note_to_type_folder, rename_note, MoveResult, RenameResult};
|
||||
pub use trash::{delete_note, is_file_trashed, purge_trash};
|
||||
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,
|
||||
@@ -1261,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]
|
||||
|
||||
@@ -344,10 +344,16 @@ pub fn move_note_to_type_folder(
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -366,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)
|
||||
@@ -397,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,
|
||||
@@ -455,6 +472,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -491,6 +509,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -515,6 +534,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -529,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());
|
||||
}
|
||||
|
||||
@@ -549,6 +574,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -572,6 +598,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"New Name",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -594,6 +621,7 @@ mod tests {
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
new_title,
|
||||
None,
|
||||
)
|
||||
.expect("rename_note should succeed");
|
||||
|
||||
@@ -649,6 +677,82 @@ mod tests {
|
||||
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]
|
||||
@@ -826,6 +930,131 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -88,6 +88,47 @@ pub fn is_file_trashed(path: &Path) -> bool {
|
||||
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.
|
||||
@@ -342,4 +383,81 @@ mod tests {
|
||||
);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
173
src/App.tsx
173
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'
|
||||
@@ -60,42 +63,6 @@ declare global {
|
||||
|
||||
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[]) => {
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try { await entryActions.handleArchiveNote(path); ok++ }
|
||||
catch { /* error toast already shown by flushBeforeAction */ }
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
const handleBulkTrash = useCallback(async (paths: string[]) => {
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try { await entryActions.handleTrashNote(path); ok++ }
|
||||
catch { /* error toast already shown by flushBeforeAction */ }
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 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)
|
||||
@@ -201,53 +168,13 @@ 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 })
|
||||
|
||||
// O(1) path lookup map — rebuilt only when vault.entries changes
|
||||
const entriesByPath = useMemo(() => {
|
||||
const map = new Map<string, VaultEntry>()
|
||||
for (const e of vault.entries) map.set(e.path, e)
|
||||
return map
|
||||
}, [vault.entries])
|
||||
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) => {
|
||||
@@ -306,10 +233,16 @@ function App() {
|
||||
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({
|
||||
updateEntry: vault.updateEntry,
|
||||
setTabs: notes.setTabs, setToastMessage, onAfterSave,
|
||||
onNotePersisted: vault.clearUnsaved,
|
||||
onNotePersisted,
|
||||
})
|
||||
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
|
||||
|
||||
@@ -352,14 +285,25 @@ function App() {
|
||||
}
|
||||
}, [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 })
|
||||
|
||||
@@ -372,17 +316,13 @@ function App() {
|
||||
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)
|
||||
|
||||
@@ -391,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
|
||||
@@ -489,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 () => {
|
||||
@@ -516,6 +449,9 @@ 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,
|
||||
@@ -582,7 +518,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} 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} />
|
||||
@@ -611,6 +547,7 @@ function App() {
|
||||
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
|
||||
onDeleteProperty={notes.handleDeleteProperty}
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
vaultPath={resolvedPath}
|
||||
@@ -618,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}
|
||||
@@ -627,8 +564,8 @@ 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}
|
||||
@@ -667,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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
@@ -358,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(
|
||||
|
||||
@@ -45,6 +45,7 @@ interface EditorProps {
|
||||
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
|
||||
@@ -120,7 +121,7 @@ export const Editor = memo(function Editor({
|
||||
onLoadDiff, onLoadDiffAtCommit, getNoteStatus, onCreateNote,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
@@ -149,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)
|
||||
|
||||
@@ -165,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,
|
||||
})
|
||||
@@ -223,6 +237,7 @@ export const Editor = memo(function Editor({
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
isDarkTheme={isDarkTheme}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
/>
|
||||
}
|
||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
@@ -245,6 +260,7 @@ export const Editor = memo(function Editor({
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onOpenNote={onNavigateWikilink}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ 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
|
||||
@@ -33,7 +34,7 @@ export function EditorRightPanel({
|
||||
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, openTabs,
|
||||
noteList, noteListFilter,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onOpenNote,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
}: EditorRightPanelProps) {
|
||||
if (showAIChat) {
|
||||
@@ -79,6 +80,7 @@ export function EditorRightPanel({
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ interface InspectorProps {
|
||||
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(
|
||||
@@ -114,7 +115,7 @@ function EmptyInspector() {
|
||||
|
||||
export function Inspector({
|
||||
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
|
||||
}: InspectorProps) {
|
||||
const referencedBy = useReferencedBy(entry, entries)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy)
|
||||
@@ -157,6 +158,7 @@ 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} />
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ 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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,7 +502,7 @@ function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, getNot
|
||||
return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, 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()
|
||||
@@ -505,7 +523,11 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
|
||||
|
||||
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} onPrefetch={prefetchNoteContent} />
|
||||
@@ -516,7 +538,7 @@ function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifi
|
||||
|
||||
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, modifiedFiles, modifi
|
||||
)}
|
||||
</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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, 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(() => {
|
||||
|
||||
@@ -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])
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 { 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,7 +213,13 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{onAddRef && <InlineAddNote entries={entries} onAdd={onAddRef} />}
|
||||
{onAddRef && (
|
||||
<InlineAddNote
|
||||
entries={entries}
|
||||
onAdd={onAddRef}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -65,6 +65,9 @@ interface AppCommandsConfig {
|
||||
vaultCount?: number
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
trashedCount?: number
|
||||
onReopenClosedTab?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
@@ -114,6 +117,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onGoForward: config.onGoForward,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onReopenClosedTab: config.onReopenClosedTab,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
})
|
||||
@@ -153,6 +157,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
onEmptyTrash: config.onEmptyTrash,
|
||||
onReopenClosedTab: config.onReopenClosedTab,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
activeTabPath: config.activeTabPath,
|
||||
@@ -206,6 +212,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
vaultCount: config.vaultCount,
|
||||
mcpStatus: config.mcpStatus,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onEmptyTrash: config.onEmptyTrash,
|
||||
trashedCount: config.trashedCount,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
|
||||
@@ -183,6 +183,23 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onZoomReset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+T triggers reopen closed tab', () => {
|
||||
const actions = makeActions()
|
||||
const onReopenClosedTab = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onReopenClosedTab }))
|
||||
fireKey('t', { metaKey: true, shiftKey: true })
|
||||
expect(onReopenClosedTab).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+T does not trigger other shortcuts', () => {
|
||||
const actions = makeActions()
|
||||
const onReopenClosedTab = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onReopenClosedTab }))
|
||||
fireKey('t', { metaKey: true, shiftKey: true })
|
||||
expect(actions.onQuickOpen).not.toHaveBeenCalled()
|
||||
expect(actions.onCreateNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+I triggers toggle AI chat', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
|
||||
@@ -19,6 +19,7 @@ interface KeyboardActions {
|
||||
onGoForward?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onReopenClosedTab?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
}
|
||||
@@ -64,7 +65,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, activeTabPathRef, handleCloseTabRef,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab, activeTabPathRef, handleCloseTabRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
|
||||
@@ -100,11 +101,17 @@ export function useAppKeyboard({
|
||||
onSearch()
|
||||
return
|
||||
}
|
||||
// Cmd+Shift+T: reopen last closed tab
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 't' || e.key === 'T')) {
|
||||
e.preventDefault()
|
||||
onReopenClosedTab?.()
|
||||
return
|
||||
}
|
||||
if (!handleViewModeKey(e, onSetViewMode)) {
|
||||
handleCmdKey(e, cmdKeyMap)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor])
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onReopenClosedTab])
|
||||
}
|
||||
|
||||
134
src/hooks/useAppNavigation.test.ts
Normal file
134
src/hooks/useAppNavigation.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useAppNavigation } from './useAppNavigation'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(path: string): VaultEntry {
|
||||
return { path, filename: path.split('/').pop()!, title: path, isA: null, aliases: [] } as VaultEntry
|
||||
}
|
||||
|
||||
function makeTab(entry: VaultEntry) {
|
||||
return { entry, content: '' }
|
||||
}
|
||||
|
||||
describe('useAppNavigation', () => {
|
||||
let onSelectNote: ReturnType<typeof vi.fn>
|
||||
let onSwitchTab: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
onSelectNote = vi.fn()
|
||||
onSwitchTab = vi.fn()
|
||||
})
|
||||
|
||||
function renderNav(overrides: {
|
||||
entries?: VaultEntry[]
|
||||
tabs?: Array<{ entry: VaultEntry; content: string }>
|
||||
activeTabPath?: string | null
|
||||
} = {}) {
|
||||
const entries = overrides.entries ?? [makeEntry('/a.md'), makeEntry('/b.md'), makeEntry('/c.md')]
|
||||
const tabs = overrides.tabs ?? []
|
||||
const activeTabPath = overrides.activeTabPath ?? null
|
||||
return renderHook(() =>
|
||||
useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
|
||||
)
|
||||
}
|
||||
|
||||
// --- entriesByPath ---
|
||||
|
||||
describe('entriesByPath', () => {
|
||||
it('builds a Map from entries for O(1) lookup', () => {
|
||||
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
|
||||
const { result } = renderNav({ entries })
|
||||
expect(result.current.entriesByPath.get('/a.md')).toBe(entries[0])
|
||||
expect(result.current.entriesByPath.get('/b.md')).toBe(entries[1])
|
||||
expect(result.current.entriesByPath.get('/missing.md')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// --- canGoBack / canGoForward initial state ---
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts with canGoBack=false and canGoForward=false', () => {
|
||||
const { result } = renderNav()
|
||||
expect(result.current.canGoBack).toBe(false)
|
||||
expect(result.current.canGoForward).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// --- navigation history integration ---
|
||||
|
||||
describe('navigation via activeTabPath changes', () => {
|
||||
it('pushes to history when activeTabPath changes, enabling goBack', () => {
|
||||
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
|
||||
const tabA = makeTab(entries[0])
|
||||
const tabB = makeTab(entries[1])
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeTabPath, tabs }) =>
|
||||
useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
|
||||
{ initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabA] } },
|
||||
)
|
||||
|
||||
// Navigate to /b.md
|
||||
rerender({ activeTabPath: '/b.md', tabs: [tabA, tabB] })
|
||||
|
||||
expect(result.current.canGoBack).toBe(true)
|
||||
expect(result.current.canGoForward).toBe(false)
|
||||
})
|
||||
|
||||
it('handleGoBack switches to the tab if it is open', () => {
|
||||
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
|
||||
const tabA = makeTab(entries[0])
|
||||
const tabB = makeTab(entries[1])
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeTabPath, tabs }) =>
|
||||
useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
|
||||
{ initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabA, tabB] } },
|
||||
)
|
||||
|
||||
rerender({ activeTabPath: '/b.md', tabs: [tabA, tabB] })
|
||||
|
||||
act(() => { result.current.handleGoBack() })
|
||||
|
||||
expect(onSwitchTab).toHaveBeenCalledWith('/a.md')
|
||||
})
|
||||
|
||||
it('handleGoBack opens entry via onSelectNote if not in tabs', () => {
|
||||
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
|
||||
const tabB = makeTab(entries[1])
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeTabPath, tabs }) =>
|
||||
useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
|
||||
{ initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabB] } },
|
||||
)
|
||||
|
||||
rerender({ activeTabPath: '/b.md', tabs: [tabB] })
|
||||
|
||||
act(() => { result.current.handleGoBack() })
|
||||
|
||||
expect(onSelectNote).toHaveBeenCalledWith(entries[0])
|
||||
})
|
||||
|
||||
it('handleGoForward works after going back', () => {
|
||||
const entries = [makeEntry('/a.md'), makeEntry('/b.md')]
|
||||
const tabA = makeTab(entries[0])
|
||||
const tabB = makeTab(entries[1])
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeTabPath, tabs }) =>
|
||||
useAppNavigation({ entries, tabs, activeTabPath, onSelectNote, onSwitchTab }),
|
||||
{ initialProps: { activeTabPath: '/a.md' as string | null, tabs: [tabA, tabB] } },
|
||||
)
|
||||
|
||||
rerender({ activeTabPath: '/b.md', tabs: [tabA, tabB] })
|
||||
act(() => { result.current.handleGoBack() })
|
||||
|
||||
expect(result.current.canGoForward).toBe(true)
|
||||
act(() => { result.current.handleGoForward() })
|
||||
|
||||
expect(onSwitchTab).toHaveBeenCalledWith('/b.md')
|
||||
})
|
||||
})
|
||||
})
|
||||
89
src/hooks/useAppNavigation.ts
Normal file
89
src/hooks/useAppNavigation.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useNavigationHistory } from './useNavigationHistory'
|
||||
import { useNavigationGestures } from './useNavigationGestures'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface TabLike {
|
||||
entry: { path: string }
|
||||
}
|
||||
|
||||
interface UseAppNavigationParams {
|
||||
entries: VaultEntry[]
|
||||
tabs: TabLike[]
|
||||
activeTabPath: string | null
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onSwitchTab: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates browser-style back/forward navigation for the app:
|
||||
* - Navigation history (push on tab change, back/forward traversal)
|
||||
* - Mouse button & trackpad gesture bindings
|
||||
* - O(1) path→entry lookup map
|
||||
*/
|
||||
export function useAppNavigation({
|
||||
entries,
|
||||
tabs,
|
||||
activeTabPath,
|
||||
onSelectNote,
|
||||
onSwitchTab,
|
||||
}: UseAppNavigationParams) {
|
||||
const navHistory = useNavigationHistory()
|
||||
|
||||
// Push to navigation history whenever the active tab changes (user-initiated)
|
||||
const navFromHistoryRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (activeTabPath && !navFromHistoryRef.current) {
|
||||
navHistory.push(activeTabPath)
|
||||
}
|
||||
navFromHistoryRef.current = false
|
||||
}, [activeTabPath]) // eslint-disable-line react-hooks/exhaustive-deps -- navHistory.push is stable
|
||||
|
||||
const isEntryExists = useCallback(
|
||||
(path: string) => entries.some(e => e.path === path),
|
||||
[entries],
|
||||
)
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
const target = navHistory.goBack(isEntryExists)
|
||||
if (target) {
|
||||
navFromHistoryRef.current = true
|
||||
if (tabs.some(t => t.entry.path === target)) {
|
||||
onSwitchTab(target)
|
||||
} else {
|
||||
const entry = entries.find(e => e.path === target)
|
||||
if (entry) onSelectNote(entry)
|
||||
}
|
||||
}
|
||||
}, [navHistory, isEntryExists, entries, tabs, onSelectNote, onSwitchTab])
|
||||
|
||||
const handleGoForward = useCallback(() => {
|
||||
const target = navHistory.goForward(isEntryExists)
|
||||
if (target) {
|
||||
navFromHistoryRef.current = true
|
||||
if (tabs.some(t => t.entry.path === target)) {
|
||||
onSwitchTab(target)
|
||||
} else {
|
||||
const entry = entries.find(e => e.path === target)
|
||||
if (entry) onSelectNote(entry)
|
||||
}
|
||||
}
|
||||
}, [navHistory, isEntryExists, entries, tabs, onSelectNote, onSwitchTab])
|
||||
|
||||
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
|
||||
|
||||
// O(1) path lookup map — rebuilt only when entries change
|
||||
const entriesByPath = useMemo(() => {
|
||||
const map = new Map<string, VaultEntry>()
|
||||
for (const e of entries) map.set(e.path, e)
|
||||
return map
|
||||
}, [entries])
|
||||
|
||||
return {
|
||||
handleGoBack,
|
||||
handleGoForward,
|
||||
canGoBack: navHistory.canGoBack,
|
||||
canGoForward: navHistory.canGoForward,
|
||||
entriesByPath,
|
||||
}
|
||||
}
|
||||
180
src/hooks/useBulkActions.test.ts
Normal file
180
src/hooks/useBulkActions.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useBulkActions } from './useBulkActions'
|
||||
|
||||
describe('useBulkActions', () => {
|
||||
let handleArchiveNote: ReturnType<typeof vi.fn>
|
||||
let handleTrashNote: ReturnType<typeof vi.fn>
|
||||
let handleRestoreNote: ReturnType<typeof vi.fn>
|
||||
let setToastMessage: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
handleArchiveNote = vi.fn().mockResolvedValue(undefined)
|
||||
handleTrashNote = vi.fn().mockResolvedValue(undefined)
|
||||
handleRestoreNote = vi.fn().mockResolvedValue(undefined)
|
||||
setToastMessage = vi.fn()
|
||||
})
|
||||
|
||||
function renderBulkActions() {
|
||||
return renderHook(() =>
|
||||
useBulkActions(
|
||||
{ handleArchiveNote, handleTrashNote, handleRestoreNote },
|
||||
setToastMessage,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- handleBulkArchive ---
|
||||
|
||||
describe('handleBulkArchive', () => {
|
||||
it('archives each path and shows plural toast for multiple notes', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
expect(handleArchiveNote).toHaveBeenCalledTimes(2)
|
||||
expect(handleArchiveNote).toHaveBeenCalledWith('/vault/a.md')
|
||||
expect(handleArchiveNote).toHaveBeenCalledWith('/vault/b.md')
|
||||
expect(setToastMessage).toHaveBeenCalledWith('2 notes archived')
|
||||
})
|
||||
|
||||
it('shows singular toast when one note archived', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkArchive(['/vault/a.md'])
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('1 note archived')
|
||||
})
|
||||
|
||||
it('does not show toast when empty array given', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkArchive([])
|
||||
})
|
||||
expect(handleArchiveNote).not.toHaveBeenCalled()
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips failed paths and only counts successes in toast', async () => {
|
||||
handleArchiveNote
|
||||
.mockResolvedValueOnce(undefined) // /vault/a.md succeeds
|
||||
.mockRejectedValueOnce(new Error('fail')) // /vault/b.md fails
|
||||
.mockResolvedValueOnce(undefined) // /vault/c.md succeeds
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
|
||||
})
|
||||
expect(handleArchiveNote).toHaveBeenCalledTimes(3)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('2 notes archived')
|
||||
})
|
||||
|
||||
it('shows no toast when all paths fail', async () => {
|
||||
handleArchiveNote.mockRejectedValue(new Error('fail'))
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkArchive(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --- handleBulkTrash ---
|
||||
|
||||
describe('handleBulkTrash', () => {
|
||||
it('trashes each path and shows plural toast', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkTrash(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
expect(handleTrashNote).toHaveBeenCalledTimes(2)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('2 notes moved to trash')
|
||||
})
|
||||
|
||||
it('shows singular toast when one note trashed', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkTrash(['/vault/a.md'])
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('1 note moved to trash')
|
||||
})
|
||||
|
||||
it('does not show toast when empty array given', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkTrash([])
|
||||
})
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips failed paths and counts only successes', async () => {
|
||||
handleTrashNote
|
||||
.mockRejectedValueOnce(new Error('fail'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkTrash(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('1 note moved to trash')
|
||||
})
|
||||
|
||||
it('shows no toast when all fail', async () => {
|
||||
handleTrashNote.mockRejectedValue(new Error('fail'))
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkTrash(['/vault/a.md'])
|
||||
})
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --- handleBulkRestore ---
|
||||
|
||||
describe('handleBulkRestore', () => {
|
||||
it('restores each path and shows plural toast', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
|
||||
})
|
||||
expect(handleRestoreNote).toHaveBeenCalledTimes(3)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('3 notes restored')
|
||||
})
|
||||
|
||||
it('shows singular toast when one note restored', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkRestore(['/vault/only.md'])
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('1 note restored')
|
||||
})
|
||||
|
||||
it('does not show toast when empty array given', async () => {
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkRestore([])
|
||||
})
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('partial failure: counts only successful restores in toast', async () => {
|
||||
handleRestoreNote
|
||||
.mockResolvedValueOnce(undefined) // /vault/a.md ok
|
||||
.mockRejectedValueOnce(new Error('not found')) // /vault/b.md fails
|
||||
.mockResolvedValueOnce(undefined) // /vault/c.md ok
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
|
||||
})
|
||||
expect(handleRestoreNote).toHaveBeenCalledTimes(3)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('2 notes restored')
|
||||
})
|
||||
|
||||
it('shows no toast when all restores fail', async () => {
|
||||
handleRestoreNote.mockRejectedValue(new Error('fail'))
|
||||
const { result } = renderBulkActions()
|
||||
await act(async () => {
|
||||
await result.current.handleBulkRestore(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
41
src/hooks/useBulkActions.ts
Normal file
41
src/hooks/useBulkActions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
interface BulkEntryActions {
|
||||
handleArchiveNote: (path: string) => Promise<void>
|
||||
handleTrashNote: (path: string) => Promise<void>
|
||||
handleRestoreNote: (path: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useBulkActions(
|
||||
entryActions: BulkEntryActions,
|
||||
setToastMessage: (msg: string | null) => void,
|
||||
) {
|
||||
const handleBulkArchive = useCallback(async (paths: string[]) => {
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try { await entryActions.handleArchiveNote(path); ok++ }
|
||||
catch { /* error toast already shown by flushBeforeAction */ }
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
const handleBulkTrash = useCallback(async (paths: string[]) => {
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try { await entryActions.handleTrashNote(path); ok++ }
|
||||
catch { /* error toast already shown by flushBeforeAction */ }
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
const handleBulkRestore = useCallback(async (paths: string[]) => {
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try { await entryActions.handleRestoreNote(path); ok++ }
|
||||
catch { /* skip — error toast already shown */ }
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} restored`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
return { handleBulkArchive, handleBulkTrash, handleBulkRestore }
|
||||
}
|
||||
99
src/hooks/useClosedTabHistory.test.ts
Normal file
99
src/hooks/useClosedTabHistory.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useClosedTabHistory } from './useClosedTabHistory'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const stubEntry = (path: string): VaultEntry => ({
|
||||
path, filename: path.split('/').pop() ?? '', title: path.split('/').pop()?.replace(/\.md$/, '') ?? '',
|
||||
isA: 'Note', aliases: [], belongsTo: [], relatedTo: [], status: 'Active', owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 0, createdAt: 0, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
|
||||
})
|
||||
|
||||
describe('useClosedTabHistory', () => {
|
||||
it('starts with empty history', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
expect(result.current.canReopen).toBe(false)
|
||||
})
|
||||
|
||||
it('records a closed tab and allows reopening', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
|
||||
act(() => { result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md')) })
|
||||
|
||||
expect(result.current.canReopen).toBe(true)
|
||||
const entry = result.current.pop()
|
||||
expect(entry?.path).toBe('/vault/a.md')
|
||||
expect(entry?.index).toBe(0)
|
||||
expect(result.current.canReopen).toBe(false)
|
||||
})
|
||||
|
||||
it('pops in LIFO order', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
|
||||
act(() => {
|
||||
result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md'))
|
||||
result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md'))
|
||||
result.current.push('/vault/c.md', 2, stubEntry('/vault/c.md'))
|
||||
})
|
||||
|
||||
expect(result.current.pop()?.path).toBe('/vault/c.md')
|
||||
expect(result.current.pop()?.path).toBe('/vault/b.md')
|
||||
expect(result.current.pop()?.path).toBe('/vault/a.md')
|
||||
expect(result.current.pop()).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when popping empty history', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
expect(result.current.pop()).toBeNull()
|
||||
})
|
||||
|
||||
it('caps history at 20 entries', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
result.current.push(`/vault/${i}.md`, i, stubEntry(`/vault/${i}.md`))
|
||||
}
|
||||
})
|
||||
|
||||
// Should only have last 20 entries (5-24)
|
||||
const first = result.current.pop()
|
||||
expect(first?.path).toBe('/vault/24.md')
|
||||
|
||||
// Pop remaining 19
|
||||
for (let i = 0; i < 19; i++) {
|
||||
result.current.pop()
|
||||
}
|
||||
expect(result.current.pop()).toBeNull()
|
||||
})
|
||||
|
||||
it('deduplicates: closing same path twice keeps only latest entry', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
|
||||
act(() => {
|
||||
result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md'))
|
||||
result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md'))
|
||||
result.current.push('/vault/a.md', 2, stubEntry('/vault/a.md')) // close a.md again at different index
|
||||
})
|
||||
|
||||
// a.md should only appear once (the latest), at the top
|
||||
expect(result.current.pop()?.path).toBe('/vault/a.md')
|
||||
expect(result.current.pop()?.path).toBe('/vault/b.md')
|
||||
expect(result.current.pop()).toBeNull()
|
||||
})
|
||||
|
||||
it('clear resets the history', () => {
|
||||
const { result } = renderHook(() => useClosedTabHistory())
|
||||
|
||||
act(() => {
|
||||
result.current.push('/vault/a.md', 0, stubEntry('/vault/a.md'))
|
||||
result.current.push('/vault/b.md', 1, stubEntry('/vault/b.md'))
|
||||
})
|
||||
|
||||
act(() => { result.current.clear() })
|
||||
|
||||
expect(result.current.canReopen).toBe(false)
|
||||
expect(result.current.pop()).toBeNull()
|
||||
})
|
||||
})
|
||||
42
src/hooks/useClosedTabHistory.ts
Normal file
42
src/hooks/useClosedTabHistory.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export interface ClosedTabEntry {
|
||||
path: string
|
||||
index: number
|
||||
entry: VaultEntry
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 20
|
||||
|
||||
export function useClosedTabHistory() {
|
||||
const stackRef = useRef<ClosedTabEntry[]>([])
|
||||
|
||||
const push = useCallback((path: string, index: number, entry: VaultEntry) => {
|
||||
const stack = stackRef.current
|
||||
// Remove any existing entry for this path (dedup)
|
||||
const filtered = stack.filter(e => e.path !== path)
|
||||
filtered.push({ path, index, entry })
|
||||
// Cap at MAX_HISTORY
|
||||
if (filtered.length > MAX_HISTORY) {
|
||||
filtered.splice(0, filtered.length - MAX_HISTORY)
|
||||
}
|
||||
stackRef.current = filtered
|
||||
}, [])
|
||||
|
||||
const pop = useCallback((): ClosedTabEntry | null => {
|
||||
const stack = stackRef.current
|
||||
if (stack.length === 0) return null
|
||||
return stack.pop() ?? null
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
stackRef.current = []
|
||||
}, [])
|
||||
|
||||
// Getter so callers see live state without re-render
|
||||
return {
|
||||
push, pop, clear,
|
||||
get canReopen() { return stackRef.current.length > 0 },
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ interface CommandRegistryConfig {
|
||||
modifiedCount: number
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
trashedCount?: number
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
@@ -199,6 +201,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onCreateType,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onEmptyTrash, trashedCount,
|
||||
onReindexVault,
|
||||
onReloadVault,
|
||||
onRepairVault,
|
||||
@@ -222,6 +225,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
|
||||
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
|
||||
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
|
||||
{ id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
@@ -284,6 +288,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onEmptyTrash, trashedCount,
|
||||
onReindexVault, onReloadVault, onRepairVault,
|
||||
])
|
||||
}
|
||||
|
||||
222
src/hooks/useDeleteActions.test.ts
Normal file
222
src/hooks/useDeleteActions.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useDeleteActions } from './useDeleteActions'
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: vi.fn(),
|
||||
}))
|
||||
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
const mockInvokeFn = mockInvoke as ReturnType<typeof vi.fn>
|
||||
|
||||
function makeEntry(path: string, trashed = false) {
|
||||
return { path, trashed } as { path: string; trashed: boolean }
|
||||
}
|
||||
|
||||
describe('useDeleteActions', () => {
|
||||
let handleCloseTab: ReturnType<typeof vi.fn>
|
||||
let removeEntry: ReturnType<typeof vi.fn>
|
||||
let setToastMessage: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
handleCloseTab = vi.fn()
|
||||
removeEntry = vi.fn()
|
||||
setToastMessage = vi.fn()
|
||||
mockInvokeFn.mockReset()
|
||||
})
|
||||
|
||||
function renderDeleteActions(entries = [makeEntry('/vault/a.md'), makeEntry('/vault/t.md', true)]) {
|
||||
return renderHook(() =>
|
||||
useDeleteActions({
|
||||
vaultPath: '/vault',
|
||||
entries,
|
||||
handleCloseTab,
|
||||
removeEntry,
|
||||
setToastMessage,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// --- trashedCount ---
|
||||
|
||||
describe('trashedCount', () => {
|
||||
it('counts trashed entries', () => {
|
||||
const { result } = renderDeleteActions([
|
||||
makeEntry('/vault/a.md'),
|
||||
makeEntry('/vault/b.md', true),
|
||||
makeEntry('/vault/c.md', true),
|
||||
])
|
||||
expect(result.current.trashedCount).toBe(2)
|
||||
})
|
||||
|
||||
it('returns 0 when no trashed entries', () => {
|
||||
const { result } = renderDeleteActions([makeEntry('/vault/a.md')])
|
||||
expect(result.current.trashedCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --- deleteNoteFromDisk ---
|
||||
|
||||
describe('deleteNoteFromDisk', () => {
|
||||
it('invokes delete_note, closes tab, removes entry, returns true', async () => {
|
||||
mockInvokeFn.mockResolvedValue(undefined)
|
||||
const { result } = renderDeleteActions()
|
||||
let ok: boolean | undefined
|
||||
await act(async () => {
|
||||
ok = await result.current.deleteNoteFromDisk('/vault/a.md')
|
||||
})
|
||||
expect(ok).toBe(true)
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('delete_note', { path: '/vault/a.md' })
|
||||
expect(handleCloseTab).toHaveBeenCalledWith('/vault/a.md')
|
||||
expect(removeEntry).toHaveBeenCalledWith('/vault/a.md')
|
||||
})
|
||||
|
||||
it('shows toast and returns false on failure', async () => {
|
||||
mockInvokeFn.mockRejectedValue(new Error('disk full'))
|
||||
const { result } = renderDeleteActions()
|
||||
let ok: boolean | undefined
|
||||
await act(async () => {
|
||||
ok = await result.current.deleteNoteFromDisk('/vault/a.md')
|
||||
})
|
||||
expect(ok).toBe(false)
|
||||
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to delete'))
|
||||
})
|
||||
})
|
||||
|
||||
// --- handleDeleteNote ---
|
||||
|
||||
describe('handleDeleteNote', () => {
|
||||
it('sets confirmDelete dialog state', async () => {
|
||||
const { result } = renderDeleteActions()
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteNote('/vault/a.md')
|
||||
})
|
||||
expect(result.current.confirmDelete).not.toBeNull()
|
||||
expect(result.current.confirmDelete?.title).toBe('Delete permanently?')
|
||||
})
|
||||
|
||||
it('onConfirm deletes the note and clears dialog', async () => {
|
||||
mockInvokeFn.mockResolvedValue(undefined)
|
||||
const { result } = renderDeleteActions()
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteNote('/vault/a.md')
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.confirmDelete!.onConfirm()
|
||||
})
|
||||
expect(result.current.confirmDelete).toBeNull()
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('delete_note', { path: '/vault/a.md' })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note permanently deleted')
|
||||
})
|
||||
})
|
||||
|
||||
// --- handleBulkDeletePermanently ---
|
||||
|
||||
describe('handleBulkDeletePermanently', () => {
|
||||
it('sets confirmDelete with correct plural title', () => {
|
||||
const { result } = renderDeleteActions()
|
||||
act(() => {
|
||||
result.current.handleBulkDeletePermanently(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
expect(result.current.confirmDelete?.title).toBe('Delete 2 notes permanently?')
|
||||
})
|
||||
|
||||
it('sets confirmDelete with singular title for one note', () => {
|
||||
const { result } = renderDeleteActions()
|
||||
act(() => {
|
||||
result.current.handleBulkDeletePermanently(['/vault/a.md'])
|
||||
})
|
||||
expect(result.current.confirmDelete?.title).toBe('Delete 1 note permanently?')
|
||||
})
|
||||
|
||||
it('onConfirm deletes all paths and shows toast', async () => {
|
||||
mockInvokeFn.mockResolvedValue(undefined)
|
||||
const { result } = renderDeleteActions()
|
||||
act(() => {
|
||||
result.current.handleBulkDeletePermanently(['/vault/a.md', '/vault/b.md'])
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.confirmDelete!.onConfirm()
|
||||
})
|
||||
expect(result.current.confirmDelete).toBeNull()
|
||||
expect(mockInvokeFn).toHaveBeenCalledTimes(2)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('2 notes permanently deleted')
|
||||
})
|
||||
})
|
||||
|
||||
// --- handleEmptyTrash ---
|
||||
|
||||
describe('handleEmptyTrash', () => {
|
||||
it('does nothing when trashedCount is 0', () => {
|
||||
const { result } = renderDeleteActions([makeEntry('/vault/a.md')])
|
||||
act(() => {
|
||||
result.current.handleEmptyTrash()
|
||||
})
|
||||
expect(result.current.confirmDelete).toBeNull()
|
||||
})
|
||||
|
||||
it('sets confirmDelete with trash count in message', () => {
|
||||
const { result } = renderDeleteActions([
|
||||
makeEntry('/vault/a.md'),
|
||||
makeEntry('/vault/t1.md', true),
|
||||
makeEntry('/vault/t2.md', true),
|
||||
])
|
||||
act(() => {
|
||||
result.current.handleEmptyTrash()
|
||||
})
|
||||
expect(result.current.confirmDelete?.title).toBe('Empty Trash?')
|
||||
expect(result.current.confirmDelete?.confirmLabel).toBe('Empty Trash')
|
||||
})
|
||||
|
||||
it('onConfirm invokes empty_trash, closes tabs, removes entries', async () => {
|
||||
mockInvokeFn.mockResolvedValue(['/vault/t1.md', '/vault/t2.md'])
|
||||
const { result } = renderDeleteActions([
|
||||
makeEntry('/vault/a.md'),
|
||||
makeEntry('/vault/t1.md', true),
|
||||
makeEntry('/vault/t2.md', true),
|
||||
])
|
||||
act(() => {
|
||||
result.current.handleEmptyTrash()
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.confirmDelete!.onConfirm()
|
||||
})
|
||||
expect(result.current.confirmDelete).toBeNull()
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('empty_trash', { vaultPath: '/vault' })
|
||||
expect(handleCloseTab).toHaveBeenCalledWith('/vault/t1.md')
|
||||
expect(handleCloseTab).toHaveBeenCalledWith('/vault/t2.md')
|
||||
expect(removeEntry).toHaveBeenCalledWith('/vault/t1.md')
|
||||
expect(removeEntry).toHaveBeenCalledWith('/vault/t2.md')
|
||||
expect(setToastMessage).toHaveBeenCalledWith('2 notes permanently deleted')
|
||||
})
|
||||
|
||||
it('shows error toast when empty_trash fails', async () => {
|
||||
mockInvokeFn.mockRejectedValue(new Error('oops'))
|
||||
const { result } = renderDeleteActions([makeEntry('/vault/t.md', true)])
|
||||
act(() => {
|
||||
result.current.handleEmptyTrash()
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.confirmDelete!.onConfirm()
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to empty trash'))
|
||||
})
|
||||
})
|
||||
|
||||
// --- setConfirmDelete ---
|
||||
|
||||
describe('setConfirmDelete', () => {
|
||||
it('can clear confirmDelete via setConfirmDelete(null)', async () => {
|
||||
const { result } = renderDeleteActions()
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteNote('/vault/a.md')
|
||||
})
|
||||
expect(result.current.confirmDelete).not.toBeNull()
|
||||
act(() => {
|
||||
result.current.setConfirmDelete(null)
|
||||
})
|
||||
expect(result.current.confirmDelete).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
105
src/hooks/useDeleteActions.ts
Normal file
105
src/hooks/useDeleteActions.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
interface ConfirmDeleteState {
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
interface UseDeleteActionsInput {
|
||||
vaultPath: string
|
||||
entries: VaultEntry[]
|
||||
handleCloseTab: (path: string) => void
|
||||
removeEntry: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
}
|
||||
|
||||
export function useDeleteActions({
|
||||
vaultPath,
|
||||
entries,
|
||||
handleCloseTab,
|
||||
removeEntry,
|
||||
setToastMessage,
|
||||
}: UseDeleteActionsInput) {
|
||||
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState | null>(null)
|
||||
|
||||
const trashedCount = useMemo(() => entries.filter(e => e.trashed).length, [entries])
|
||||
|
||||
const deleteNoteFromDisk = useCallback(async (path: string) => {
|
||||
try {
|
||||
if (isTauri()) await invoke('delete_note', { path })
|
||||
else await mockInvoke('delete_note', { path })
|
||||
handleCloseTab(path)
|
||||
removeEntry(path)
|
||||
return true
|
||||
} catch (e) {
|
||||
setToastMessage(`Failed to delete note: ${e}`)
|
||||
return false
|
||||
}
|
||||
}, [handleCloseTab, removeEntry, setToastMessage])
|
||||
|
||||
const handleDeleteNote = useCallback(async (path: string) => {
|
||||
setConfirmDelete({
|
||||
title: 'Delete permanently?',
|
||||
message: 'This note will be permanently deleted. This cannot be undone.',
|
||||
onConfirm: async () => {
|
||||
setConfirmDelete(null)
|
||||
const ok = await deleteNoteFromDisk(path)
|
||||
if (ok) setToastMessage('Note permanently deleted')
|
||||
},
|
||||
})
|
||||
}, [deleteNoteFromDisk, setToastMessage])
|
||||
|
||||
const handleBulkDeletePermanently = useCallback((paths: string[]) => {
|
||||
const count = paths.length
|
||||
setConfirmDelete({
|
||||
title: `Delete ${count} ${count === 1 ? 'note' : 'notes'} permanently?`,
|
||||
message: `${count === 1 ? 'This note' : `These ${count} notes`} will be permanently deleted. This cannot be undone.`,
|
||||
onConfirm: async () => {
|
||||
setConfirmDelete(null)
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
if (await deleteNoteFromDisk(path)) ok++
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} permanently deleted`)
|
||||
},
|
||||
})
|
||||
}, [deleteNoteFromDisk, setToastMessage])
|
||||
|
||||
const handleEmptyTrash = useCallback(() => {
|
||||
if (trashedCount === 0) return
|
||||
setConfirmDelete({
|
||||
title: 'Empty Trash?',
|
||||
message: `Permanently delete all ${trashedCount} trashed ${trashedCount === 1 ? 'note' : 'notes'}? This cannot be undone.`,
|
||||
confirmLabel: 'Empty Trash',
|
||||
onConfirm: async () => {
|
||||
setConfirmDelete(null)
|
||||
try {
|
||||
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
||||
const deleted = await tauriInvoke<string[]>('empty_trash', { vaultPath })
|
||||
for (const path of deleted) {
|
||||
handleCloseTab(path)
|
||||
removeEntry(path)
|
||||
}
|
||||
setToastMessage(`${deleted.length} note${deleted.length !== 1 ? 's' : ''} permanently deleted`)
|
||||
} catch (e) {
|
||||
setToastMessage(`Failed to empty trash: ${e}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [trashedCount, vaultPath, handleCloseTab, removeEntry, setToastMessage])
|
||||
|
||||
return {
|
||||
confirmDelete,
|
||||
setConfirmDelete,
|
||||
trashedCount,
|
||||
deleteNoteFromDisk,
|
||||
handleDeleteNote,
|
||||
handleBulkDeletePermanently,
|
||||
handleEmptyTrash,
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
function resolveEnterTarget(
|
||||
highlightIndex: number, allFiltered: string[], showCreateOption: boolean, query: string,
|
||||
): string | null {
|
||||
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) return allFiltered[highlightIndex]
|
||||
if (showCreateOption && highlightIndex === allFiltered.length) return query.trim()
|
||||
if (query.trim()) return query.trim()
|
||||
return null
|
||||
}
|
||||
|
||||
export function useDropdownKeyboard({
|
||||
highlightIndex, setHighlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel,
|
||||
}: {
|
||||
highlightIndex: number; setHighlightIndex: (i: number) => void; totalOptions: number
|
||||
allFiltered: string[]; showCreateOption: boolean; query: string
|
||||
onSave: (v: string) => void; onCancel: () => void
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const scrollHighlightedIntoView = useCallback((index: number) => {
|
||||
const items = listRef.current?.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]')
|
||||
items?.[index]?.scrollIntoView({ block: 'nearest' })
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const next = e.key === 'ArrowDown'
|
||||
? (highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0)
|
||||
: (highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1)
|
||||
setHighlightIndex(next)
|
||||
scrollHighlightedIntoView(next)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const target = resolveEnterTarget(highlightIndex, allFiltered, showCreateOption, query)
|
||||
if (target) onSave(target)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
},
|
||||
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, setHighlightIndex, scrollHighlightedIntoView],
|
||||
)
|
||||
|
||||
return { listRef, handleKeyDown }
|
||||
}
|
||||
@@ -13,8 +13,8 @@ interface EditorSaveConfig {
|
||||
setTabs: (fn: SetStateAction<any[]>) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave?: () => void
|
||||
/** Called after content is persisted — used to clear unsaved state. */
|
||||
onNotePersisted?: (path: string) => void
|
||||
/** Called after content is persisted — used to clear unsaved state and live-reload themes. */
|
||||
onNotePersisted?: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,8 +41,9 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
if (!pending) return false
|
||||
if (pathFilter && pending.path !== pathFilter) return false
|
||||
await saveNote(pending.path, pending.content)
|
||||
const savedContent = pending.content
|
||||
pendingContentRef.current = null
|
||||
onNotePersisted?.(pending.path)
|
||||
onNotePersisted?.(pending.path, savedContent)
|
||||
return true
|
||||
}, [saveNote, onNotePersisted])
|
||||
|
||||
@@ -53,7 +54,7 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
const saved = await flushPending()
|
||||
if (!saved && unsavedFallback) {
|
||||
await saveNote(unsavedFallback.path, unsavedFallback.content)
|
||||
onNotePersisted?.(unsavedFallback.path)
|
||||
onNotePersisted?.(unsavedFallback.path, unsavedFallback.content)
|
||||
setToastMessage('Saved')
|
||||
onAfterSave()
|
||||
return
|
||||
|
||||
135
src/hooks/useEditorSaveWithLinks.test.ts
Normal file
135
src/hooks/useEditorSaveWithLinks.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useEditorSaveWithLinks } from './useEditorSaveWithLinks'
|
||||
|
||||
const mockHandleContentChange = vi.fn()
|
||||
const mockHandleSave = vi.fn()
|
||||
const mockSavePendingForPath = vi.fn()
|
||||
|
||||
vi.mock('./useEditorSave', () => ({
|
||||
useEditorSave: vi.fn(() => ({
|
||||
handleContentChange: mockHandleContentChange,
|
||||
handleSave: mockHandleSave,
|
||||
savePendingForPath: mockSavePendingForPath,
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('useEditorSaveWithLinks', () => {
|
||||
let updateEntry: Mock
|
||||
let setTabs: Mock
|
||||
let setToastMessage: Mock
|
||||
let onAfterSave: Mock
|
||||
|
||||
beforeEach(() => {
|
||||
updateEntry = vi.fn()
|
||||
setTabs = vi.fn()
|
||||
setToastMessage = vi.fn()
|
||||
onAfterSave = vi.fn()
|
||||
mockHandleContentChange.mockClear()
|
||||
mockHandleSave.mockClear()
|
||||
mockSavePendingForPath.mockClear()
|
||||
})
|
||||
|
||||
function renderHookWithLinks() {
|
||||
return renderHook(() =>
|
||||
useEditorSaveWithLinks({
|
||||
updateEntry,
|
||||
setTabs,
|
||||
setToastMessage,
|
||||
onAfterSave,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it('handleContentChange delegates to useEditorSave handleContentChange', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'no links here')
|
||||
})
|
||||
|
||||
expect(mockHandleContentChange).toHaveBeenCalledWith('/note.md', 'no links here')
|
||||
})
|
||||
|
||||
it('handleContentChange calls updateEntry with extracted outgoing links when links change', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'see [[PageA]] and [[PageB]]')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
outgoingLinks: ['PageA', 'PageB'],
|
||||
})
|
||||
})
|
||||
|
||||
it('handleContentChange does NOT call updateEntry again when links have not changed', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'text [[Alpha]] more text')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Same link, different surrounding text
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'different text [[Alpha]] still')
|
||||
})
|
||||
// updateEntry should NOT have been called again — links unchanged
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('handleContentChange calls updateEntry again when links change on subsequent edit', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'see [[Alpha]]')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
outgoingLinks: ['Alpha'],
|
||||
})
|
||||
|
||||
// Now links change
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'see [[Alpha]] and [[Beta]]')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledTimes(2)
|
||||
expect(updateEntry).toHaveBeenLastCalledWith('/note.md', {
|
||||
outgoingLinks: ['Alpha', 'Beta'],
|
||||
})
|
||||
})
|
||||
|
||||
it('handleContentChange calls updateEntry with empty links on first call with no links', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
// First call with no links — prevLinksKeyRef starts as '' and extracted key is also ''
|
||||
// but since they're equal, updateEntry should NOT be called
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'plain text no links')
|
||||
})
|
||||
|
||||
// The initial ref is '' and no-links key is also '' — no change
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles pipe-separated wikilinks (display text syntax)', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', 'see [[Target|Display Text]]')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', {
|
||||
outgoingLinks: ['Target'],
|
||||
})
|
||||
})
|
||||
|
||||
it('spreads all properties from useEditorSave onto the return value', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
// handleSave and savePendingForPath should be passed through from the mock
|
||||
expect(result.current.handleSave).toBeDefined()
|
||||
expect(result.current.savePendingForPath).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
import { extractOutgoingLinks } from '../utils/wikilinks'
|
||||
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export function useEditorSaveWithLinks(config: {
|
||||
@@ -8,11 +8,15 @@ export function useEditorSaveWithLinks(config: {
|
||||
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave: () => void
|
||||
onNotePersisted?: (path: string) => void
|
||||
onNotePersisted?: (path: string, content: string) => void
|
||||
}) {
|
||||
const { updateEntry } = config
|
||||
const saveContent = useCallback((path: string, content: string) => {
|
||||
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
|
||||
updateEntry(path, {
|
||||
outgoingLinks: extractOutgoingLinks(content),
|
||||
snippet: extractSnippet(content),
|
||||
wordCount: countWords(content),
|
||||
})
|
||||
}, [updateEntry])
|
||||
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
|
||||
@@ -158,33 +158,151 @@ describe('replaceTitleInFrontmatter', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
|
||||
const blocksB = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }]
|
||||
|
||||
function makeTab(path: string, title: string) {
|
||||
return {
|
||||
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
|
||||
content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMockEditor(docRef: { current: unknown[] }) {
|
||||
return {
|
||||
document: docRef.current,
|
||||
get prosemirrorView() { return {} },
|
||||
onMount: (cb: () => void) => { cb(); return () => {} },
|
||||
replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }),
|
||||
insertBlocks: vi.fn(),
|
||||
blocksToMarkdownLossy: vi.fn(() => ''),
|
||||
blocksToHTMLLossy: vi.fn(() => ''),
|
||||
tryParseMarkdownToBlocks: vi.fn(() => blocksA),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
_docRef: docRef,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEditorTabSwap raw mode sync', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
// Initial load — parses and caches blocks
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
// Enter raw mode
|
||||
rerender({ tabs: [tabA], activeTabPath: 'a.md', rawMode: true })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
// Simulate raw editing: tab content was updated externally
|
||||
const updatedTab = {
|
||||
...tabA,
|
||||
content: '---\ntitle: Updated Title\n---\n\n# Updated Title\n\nNew body content.',
|
||||
}
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
// Exit raw mode with updated content
|
||||
rerender({ tabs: [updatedTab], activeTabPath: 'a.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
// Verify re-parse happened with updated body content
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Updated Title'),
|
||||
)
|
||||
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not skip swap when rawMode is on (editor hidden)', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
// Enter raw mode and update content
|
||||
const updatedTab = { ...tabA, content: '---\ntitle: Changed\n---\n\n# Changed\n\nEdited.' }
|
||||
rerender({ tabs: [updatedTab], activeTabPath: 'a.md', rawMode: true })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
// While in raw mode, the editor should NOT be updated
|
||||
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves content through multiple BlockNote→raw→BlockNote cycles', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
// Cycle 1: raw mode on → edit → raw mode off
|
||||
rerender({ tabs: [tabA], activeTabPath: 'a.md', rawMode: true })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
const edit1 = { ...tabA, content: '---\ntitle: Edit 1\n---\n\n# Edit 1\n\nFirst edit.' }
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
rerender({ tabs: [edit1], activeTabPath: 'a.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Edit 1'),
|
||||
)
|
||||
|
||||
// Cycle 2: raw mode on → edit → raw mode off
|
||||
rerender({ tabs: [edit1], activeTabPath: 'a.md', rawMode: true })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
const edit2 = { ...tabA, content: '---\ntitle: Edit 2\n---\n\n# Edit 2\n\nSecond edit.' }
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
rerender({ tabs: [edit2], activeTabPath: 'a.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Edit 2'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useEditorTabSwap scroll position', () => {
|
||||
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
|
||||
const blocksB = [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }]
|
||||
|
||||
function makeTab(path: string, title: string) {
|
||||
return {
|
||||
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
|
||||
content: `---\ntitle: ${title}\n---\n\n# ${title}\n\nBody of ${title}.`,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMockEditor(docRef: { current: unknown[] }) {
|
||||
const mountCallbacks: Array<() => void> = []
|
||||
return {
|
||||
document: docRef.current,
|
||||
get prosemirrorView() { return {} },
|
||||
onMount: (cb: () => void) => { mountCallbacks.push(cb); return () => {} },
|
||||
replaceBlocks: vi.fn((_old, newBlocks) => { docRef.current = newBlocks }),
|
||||
insertBlocks: vi.fn(),
|
||||
blocksToMarkdownLossy: vi.fn(() => ''),
|
||||
blocksToHTMLLossy: vi.fn(() => ''),
|
||||
tryParseMarkdownToBlocks: vi.fn(() => blocksA),
|
||||
_tiptapEditor: { commands: { setContent: vi.fn() } },
|
||||
// Make document getter dynamic
|
||||
_docRef: docRef,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks'
|
||||
import { compactMarkdown } from '../utils/compact-markdown'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -18,6 +19,8 @@ interface UseEditorTabSwapOptions {
|
||||
onH1Change?: (h1Text: string | null) => void
|
||||
/** When .current is false, handleEditorChange won't update frontmatter title from H1. */
|
||||
syncActiveRef?: React.MutableRefObject<boolean>
|
||||
/** When true, the BlockNote editor is hidden (raw/CodeMirror mode active). */
|
||||
rawMode?: boolean
|
||||
}
|
||||
|
||||
/** Strip the YAML frontmatter from raw file content, returning the body
|
||||
@@ -60,13 +63,17 @@ export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string)
|
||||
*
|
||||
* Returns `handleEditorChange`, the onChange callback for SingleEditorView.
|
||||
*/
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef }: UseEditorTabSwapOptions) {
|
||||
export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef, rawMode }: UseEditorTabSwapOptions) {
|
||||
// Cache parsed blocks + scroll position per tab path for instant switching
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
const tabCacheRef = useRef<Map<string, { blocks: any[]; scrollTop: number }>>(new Map())
|
||||
const prevActivePathRef = useRef<string | null>(null)
|
||||
const editorMountedRef = useRef(false)
|
||||
const pendingSwapRef = useRef<(() => void) | null>(null)
|
||||
const prevRawModeRef = useRef(!!rawMode)
|
||||
// Guard: prevents a subsequent effect run from re-caching stale blocks
|
||||
// while a raw-mode swap is still pending in a microtask/pendingSwap.
|
||||
const rawSwapPendingRef = useRef(false)
|
||||
|
||||
// Suppress onChange during programmatic content swaps (tab switching / initial load)
|
||||
const suppressChangeRef = useRef(false)
|
||||
@@ -111,7 +118,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
// Convert blocks → markdown, restoring wikilinks first
|
||||
const blocks = editor.document
|
||||
const restored = restoreWikilinksInBlocks(blocks)
|
||||
const bodyMarkdown = editor.blocksToMarkdownLossy(restored as typeof blocks)
|
||||
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
|
||||
|
||||
// Reconstruct full file: frontmatter + body (which now includes H1 if present)
|
||||
const [frontmatter] = splitFrontmatter(tab.content)
|
||||
@@ -136,6 +143,15 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
const prevPath = prevActivePathRef.current
|
||||
const pathChanged = prevPath !== activeTabPath
|
||||
|
||||
// Detect raw mode transition: true → false means we need to re-parse
|
||||
// from tab.content since the cached blocks are stale.
|
||||
const rawModeJustEnded = prevRawModeRef.current && !rawMode
|
||||
prevRawModeRef.current = !!rawMode
|
||||
|
||||
// While raw mode is active the BlockNote editor is hidden — skip all
|
||||
// swap logic to avoid touching the invisible editor.
|
||||
if (rawMode) return
|
||||
|
||||
// Save current editor state + scroll position for the tab we're leaving
|
||||
if (prevPath && pathChanged && editorMountedRef.current) {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
@@ -146,19 +162,31 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
}
|
||||
prevActivePathRef.current = activeTabPath
|
||||
|
||||
// When tab content updates but the active tab stays the same (e.g. after
|
||||
// Cmd+S save), refresh the cache with the current editor blocks so a later
|
||||
// tab switch doesn't revert to stale content. Do NOT re-apply blocks —
|
||||
// the editor already shows the user's edits.
|
||||
if (!pathChanged) {
|
||||
if (activeTabPath && editorMountedRef.current) {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
cache.set(activeTabPath, {
|
||||
blocks: editor.document,
|
||||
scrollTop: scrollEl?.scrollTop ?? 0,
|
||||
})
|
||||
if (rawModeJustEnded && activeTabPath) {
|
||||
// Raw mode just ended — invalidate stale cached blocks so we
|
||||
// re-parse from the latest tab.content below.
|
||||
cache.delete(activeTabPath)
|
||||
rawSwapPendingRef.current = true
|
||||
} else {
|
||||
// While a raw-mode swap is pending (scheduled via microtask), a second
|
||||
// effect run can fire due to the tabs prop updating. Skip re-caching
|
||||
// stale editor.document to avoid poisoning the cache before doSwap runs.
|
||||
if (rawSwapPendingRef.current) return
|
||||
|
||||
// When tab content updates but the active tab stays the same (e.g. after
|
||||
// Cmd+S save), refresh the cache with the current editor blocks so a later
|
||||
// tab switch doesn't revert to stale content. Do NOT re-apply blocks —
|
||||
// the editor already shows the user's edits.
|
||||
if (activeTabPath && editorMountedRef.current) {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
cache.set(activeTabPath, {
|
||||
blocks: editor.document,
|
||||
scrollTop: scrollEl?.scrollTop ?? 0,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeTabPath) return
|
||||
@@ -201,6 +229,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
const doSwap = () => {
|
||||
// Guard: bail if user switched tabs since this swap was scheduled
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
rawSwapPendingRef.current = false
|
||||
|
||||
if (cache.has(targetPath)) {
|
||||
const cached = cache.get(targetPath)!
|
||||
@@ -267,7 +296,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
|
||||
} else {
|
||||
pendingSwapRef.current = doSwap
|
||||
}
|
||||
}, [activeTabPath, tabs, editor])
|
||||
}, [activeTabPath, tabs, editor, rawMode])
|
||||
|
||||
// Clean up cache entries when tabs are closed
|
||||
const tabPathsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { uploadImageFile, useImageDrop } from './useImageDrop'
|
||||
import { createRef } from 'react'
|
||||
|
||||
let tauriMode = false
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
isTauri: () => tauriMode,
|
||||
}))
|
||||
|
||||
type DragDropEvent = { payload: { type: string; paths: string[]; position: { x: number; y: number } } }
|
||||
type DragDropCallback = (event: DragDropEvent) => void
|
||||
let capturedDragDropHandler: DragDropCallback | null = null
|
||||
|
||||
vi.mock('@tauri-apps/api/webview', () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: vi.fn((cb: DragDropCallback) => {
|
||||
capturedDragDropHandler = cb
|
||||
return Promise.resolve(() => { capturedDragDropHandler = null })
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
// JSDOM lacks DragEvent and File.arrayBuffer — polyfill for tests
|
||||
@@ -68,10 +83,7 @@ describe('uploadImageFile', () => {
|
||||
})
|
||||
|
||||
it('passes file to Tauri save_image in Tauri mode', async () => {
|
||||
const mockTauri = await import('../mock-tauri')
|
||||
const originalIsTauri = mockTauri.isTauri
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(mockTauri as any).isTauri = () => true
|
||||
tauriMode = true
|
||||
|
||||
const { invoke, convertFileSrc } = await import('@tauri-apps/api/core')
|
||||
vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-test.png')
|
||||
@@ -88,8 +100,7 @@ describe('uploadImageFile', () => {
|
||||
})
|
||||
expect(url).toBe('asset://localhost/vault/attachments/123-test.png')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(mockTauri as any).isTauri = originalIsTauri
|
||||
tauriMode = false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -168,3 +179,75 @@ describe('useImageDrop', () => {
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useImageDrop — Tauri native drag-drop', () => {
|
||||
let container: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
tauriMode = true
|
||||
capturedDragDropHandler = null
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
tauriMode = false
|
||||
capturedDragDropHandler = null
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function renderImageDropTauri(opts?: { onImageUrl?: (url: string) => void; vaultPath?: string }) {
|
||||
const ref = createRef<HTMLDivElement>()
|
||||
Object.defineProperty(ref, 'current', { value: container, writable: true })
|
||||
return renderHook(() => useImageDrop({ containerRef: ref, ...opts }))
|
||||
}
|
||||
|
||||
it('does not set isDragOver on Tauri over event (internal drags are indistinguishable)', async () => {
|
||||
const { result } = renderImageDropTauri()
|
||||
|
||||
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
||||
|
||||
act(() => {
|
||||
capturedDragDropHandler!({ payload: { type: 'over', paths: [], position: { x: 100, y: 100 } } })
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
|
||||
it('resets isDragOver on Tauri drop event', async () => {
|
||||
const onImageUrl = vi.fn()
|
||||
const { result } = renderImageDropTauri({ onImageUrl, vaultPath: '/vault' })
|
||||
|
||||
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
||||
|
||||
// Set isDragOver via HTML5 dragover (simulates real OS file drag)
|
||||
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
||||
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
||||
expect(result.current.isDragOver).toBe(true)
|
||||
|
||||
act(() => {
|
||||
capturedDragDropHandler!({
|
||||
payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } },
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
|
||||
it('resets isDragOver on Tauri cancel event', async () => {
|
||||
const { result } = renderImageDropTauri()
|
||||
|
||||
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
||||
|
||||
// Set isDragOver via HTML5 dragover first
|
||||
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
||||
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
||||
expect(result.current.isDragOver).toBe(true)
|
||||
|
||||
act(() => {
|
||||
capturedDragDropHandler!({ payload: { type: 'cancel', paths: [], position: { x: 0, y: 0 } } })
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,8 +109,9 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr
|
||||
unlisten = await getCurrentWebview().onDragDropEvent((event) => {
|
||||
const payload = event.payload
|
||||
if (payload.type === 'over') {
|
||||
// Tauri 'over' events don't include paths — show overlay for any drag
|
||||
setIsDragOver(true)
|
||||
// Tauri 'over' events don't include paths and can't distinguish
|
||||
// OS file drags from internal drags (tabs, blocks). Let the HTML5
|
||||
// dragover handler drive isDragOver — it checks hasImageFiles().
|
||||
} else if (payload.type === 'drop') {
|
||||
setIsDragOver(false)
|
||||
const imagePaths = payload.paths.filter(isImagePath)
|
||||
|
||||
12
src/hooks/useLayoutPanels.ts
Normal file
12
src/hooks/useLayoutPanels.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export 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 }
|
||||
}
|
||||
@@ -36,6 +36,7 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onInstallMcp: vi.fn(),
|
||||
onReindexVault: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
onReopenClosedTab: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
|
||||
activeTabPath: '/vault/test.md',
|
||||
@@ -313,6 +314,13 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onReloadVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// File menu: reopen closed tab
|
||||
it('file-reopen-closed-tab triggers reopen closed tab', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('file-reopen-closed-tab', h)
|
||||
expect(h.onReopenClosedTab).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
it('unknown event ID does nothing', () => {
|
||||
const h = makeHandlers()
|
||||
|
||||
@@ -35,9 +35,11 @@ export interface MenuEventHandlers {
|
||||
onResolveConflicts?: () => void
|
||||
onViewChanges?: () => void
|
||||
onInstallMcp?: () => void
|
||||
onReopenClosedTab?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
onEmptyTrash?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
activeTabPath: string | null
|
||||
@@ -81,6 +83,8 @@ type OptionalHandler =
|
||||
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
|
||||
| 'onCreateTheme' | 'onRestoreDefaultThemes'
|
||||
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
|
||||
| 'onEmptyTrash'
|
||||
| 'onReopenClosedTab'
|
||||
|
||||
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'view-go-back': 'onGoBack',
|
||||
@@ -102,6 +106,8 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-reindex': 'onReindexVault',
|
||||
'vault-reload': 'onReloadVault',
|
||||
'vault-repair': 'onRepairVault',
|
||||
'note-empty-trash': 'onEmptyTrash',
|
||||
'file-reopen-closed-tab': 'onReopenClosedTab',
|
||||
}
|
||||
|
||||
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
slugify,
|
||||
needsRenameOnSave,
|
||||
buildNewEntry,
|
||||
generateUntitledName,
|
||||
entryMatchesTarget,
|
||||
@@ -77,13 +78,37 @@ describe('slugify', () => {
|
||||
expect(slugify('--hello--')).toBe('hello')
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
it('handles empty string with fallback', () => {
|
||||
expect(slugify('')).toBe('untitled')
|
||||
})
|
||||
|
||||
it('collapses multiple separators into one hyphen', () => {
|
||||
expect(slugify('hello world---foo')).toBe('hello-world-foo')
|
||||
})
|
||||
|
||||
it('returns fallback for strings with only special characters', () => {
|
||||
// slugify('+++') should not return empty string — it causes invalid paths
|
||||
expect(slugify('+++')).not.toBe('')
|
||||
expect(slugify('!!!')).not.toBe('')
|
||||
expect(slugify('---')).not.toBe('')
|
||||
expect(slugify('@#$')).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('needsRenameOnSave', () => {
|
||||
it('returns true when filename does not match title slug', () => {
|
||||
expect(needsRenameOnSave('My New Note', 'untitled-note.md')).toBe(true)
|
||||
expect(needsRenameOnSave('Run good ads for newsletter', 'untitled-note-9.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when filename matches title slug', () => {
|
||||
expect(needsRenameOnSave('My Note', 'my-note.md')).toBe(false)
|
||||
expect(needsRenameOnSave('Hello World', 'hello-world.md')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for untitled note with matching slug', () => {
|
||||
expect(needsRenameOnSave('Untitled note', 'untitled-note.md')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNewEntry', () => {
|
||||
@@ -157,32 +182,37 @@ describe('generateUntitledName', () => {
|
||||
describe('entryMatchesTarget', () => {
|
||||
it('matches by exact title (case-insensitive)', () => {
|
||||
const entry = makeEntry({ title: 'My Project' })
|
||||
expect(entryMatchesTarget(entry, 'my project', 'my project')).toBe(true)
|
||||
expect(entryMatchesTarget(entry, 'my project')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches by alias', () => {
|
||||
const entry = makeEntry({ aliases: ['MP', 'TheProject'] })
|
||||
expect(entryMatchesTarget(entry, 'mp', 'mp')).toBe(true)
|
||||
expect(entryMatchesTarget(entry, 'mp')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches by path stem (relative to Laputa)', () => {
|
||||
it('matches by path suffix (type/slug)', () => {
|
||||
const entry = makeEntry({ path: '/Users/luca/Laputa/project/my-project.md' })
|
||||
expect(entryMatchesTarget(entry, 'project/my-project', 'project/my-project')).toBe(true)
|
||||
expect(entryMatchesTarget(entry, 'project/my-project')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches by filename stem', () => {
|
||||
const entry = makeEntry({ filename: 'my-project.md' })
|
||||
expect(entryMatchesTarget(entry, 'my-project', 'my-project')).toBe(true)
|
||||
expect(entryMatchesTarget(entry, 'my-project')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches when target as words matches title', () => {
|
||||
const entry = makeEntry({ title: 'my project' })
|
||||
expect(entryMatchesTarget(entry, 'project/my-project', 'my project')).toBe(true)
|
||||
expect(entryMatchesTarget(entry, 'my-project')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when nothing matches', () => {
|
||||
const entry = makeEntry({ title: 'Something Else', aliases: [], filename: 'else.md' })
|
||||
expect(entryMatchesTarget(entry, 'nonexistent', 'nonexistent')).toBe(false)
|
||||
expect(entryMatchesTarget(entry, 'nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles pipe syntax targets', () => {
|
||||
const entry = makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha' })
|
||||
expect(entryMatchesTarget(entry, 'project/alpha|Alpha Project')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -270,6 +300,20 @@ describe('resolveNewNote', () => {
|
||||
expect(entry.path).toBe('/other/vault/note/test.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
|
||||
it('produces a valid path for custom types with special characters', () => {
|
||||
const { entry } = resolveNewNote('My Note', 'Q&A', '/vault')
|
||||
expect(entry.path).not.toContain('//')
|
||||
expect(entry.path).toMatch(/\.md$/)
|
||||
expect(entry.filename).not.toBe('.md')
|
||||
})
|
||||
|
||||
it('produces a valid path when type is all special characters', () => {
|
||||
const { entry } = resolveNewNote('My Note', '+++', '/vault')
|
||||
// folder should not be empty, path should not have double slashes
|
||||
expect(entry.path).not.toContain('//')
|
||||
expect(entry.path).toMatch(/\.md$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewType', () => {
|
||||
@@ -612,6 +656,34 @@ describe('useNoteActions hook', () => {
|
||||
expect(tabContent).toContain('## Steps')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate does not throw for custom types with special characters', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('Q&A')
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
const [entry] = addEntry.mock.calls[0]
|
||||
expect(entry.isA).toBe('Q&A')
|
||||
expect(entry.path).not.toContain('//')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate does not throw for types that slugify to empty string', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('+++')
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
const [entry] = addEntry.mock.calls[0]
|
||||
expect(entry.path).not.toContain('//')
|
||||
expect(entry.filename).not.toBe('.md')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate uses template for typed notes', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
||||
@@ -938,6 +1010,42 @@ describe('useNoteActions hook', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('preserves note content after type change — never loads another note (regression)', async () => {
|
||||
// The mock updateMockFrontmatter returns '---\nupdated: true\n---\n' —
|
||||
// this represents the note's own content after the frontmatter update.
|
||||
const frontmatterUpdatedContent = '---\nupdated: true\n---\n'
|
||||
const wrongContent = '---\ntype: Project\n---\n# Feedback for Laputa\n\nCompletely different note.\n'
|
||||
|
||||
const entry = makeEntry({ path: '/test/vault/note/migrate-newsletter.md', filename: 'migrate-newsletter.md', title: 'Migrate newsletter to Beehiiv', isA: 'Note' })
|
||||
const replaceEntry = vi.fn()
|
||||
const config = makeConfig([entry])
|
||||
config.replaceEntry = replaceEntry
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/project/migrate-newsletter.md', updated_links: 0, moved: true }
|
||||
// Simulate the bug: get_note_content returns a DIFFERENT note's content
|
||||
// (e.g. path collision, stale cache, or filesystem race)
|
||||
if (cmd === 'get_note_content') return wrongContent
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
// Open the tab first so we have a tab to check
|
||||
act(() => { result.current.openTabWithContent(entry, '---\ntype: Note\n---\n# Migrate\n') })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/test/vault/note/migrate-newsletter.md', 'type', 'Project')
|
||||
})
|
||||
|
||||
// The tab content must be the note's OWN content (from the frontmatter update),
|
||||
// NEVER the content of a different note loaded via get_note_content.
|
||||
const tab = result.current.tabs.find(t => t.entry.path === '/test/vault/project/migrate-newsletter.md')
|
||||
expect(tab).toBeDefined()
|
||||
expect(tab!.content).toBe(frontmatterUpdatedContent)
|
||||
expect(tab!.content).not.toBe(wrongContent)
|
||||
})
|
||||
|
||||
it('does not move when value is empty or null-like', async () => {
|
||||
const config = makeConfig()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('')
|
||||
@@ -951,4 +1059,113 @@ describe('useNoteActions hook', () => {
|
||||
expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename note updates wikilinks', () => {
|
||||
it('handleRenameNote passes entry title as old_title to rename_note', async () => {
|
||||
const entry = makeEntry({
|
||||
path: '/test/vault/note/weekly-review.md',
|
||||
filename: 'weekly-review.md',
|
||||
title: 'Weekly Review',
|
||||
})
|
||||
const replaceEntry = vi.fn()
|
||||
const config = makeConfig([entry])
|
||||
config.replaceEntry = replaceEntry
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'rename_note') return { new_path: '/test/vault/note/sprint-retro.md', updated_files: 2 }
|
||||
if (cmd === 'get_note_content') return '---\nIs A: Note\n---\n# Sprint Retro\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameNote(
|
||||
'/test/vault/note/weekly-review.md',
|
||||
'Sprint Retro',
|
||||
'/test/vault',
|
||||
replaceEntry,
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
|
||||
vault_path: '/test/vault',
|
||||
old_path: '/test/vault/note/weekly-review.md',
|
||||
new_title: 'Sprint Retro',
|
||||
old_title: 'Weekly Review',
|
||||
}))
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Renamed — updated 2 wiki links')
|
||||
})
|
||||
|
||||
it('handleRenameNote passes null old_title when entry not found', async () => {
|
||||
const config = makeConfig([])
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'rename_note') return { new_path: '/test/vault/note/new.md', updated_files: 0 }
|
||||
if (cmd === 'get_note_content') return '# New\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameNote(
|
||||
'/test/vault/note/old.md', 'New', '/test/vault', vi.fn(),
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
|
||||
old_title: null,
|
||||
}))
|
||||
})
|
||||
|
||||
it('handleUpdateFrontmatter triggers rename when title key is changed', async () => {
|
||||
const entry = makeEntry({
|
||||
path: '/test/vault/note/old-name.md',
|
||||
filename: 'old-name.md',
|
||||
title: 'Old Name',
|
||||
})
|
||||
const replaceEntry = vi.fn()
|
||||
const config = makeConfig([entry])
|
||||
config.replaceEntry = replaceEntry
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'rename_note') return { new_path: '/test/vault/note/new-name.md', updated_files: 1 }
|
||||
if (cmd === 'get_note_content') return '---\ntitle: New Name\n---\n# New Name\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
// Open a tab for the entry so the rename can find it via tabsRef
|
||||
await act(async () => { result.current.handleSelectNote(entry) })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/test/vault/note/old-name.md', 'title', 'New Name')
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('rename_note', expect.objectContaining({
|
||||
old_path: '/test/vault/note/old-name.md',
|
||||
new_title: 'New Name',
|
||||
old_title: 'Old Name',
|
||||
}))
|
||||
expect(replaceEntry).toHaveBeenCalledWith(
|
||||
'/test/vault/note/old-name.md',
|
||||
expect.objectContaining({ path: '/test/vault/note/new-name.md', title: 'New Name' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('handleUpdateFrontmatter does not trigger rename for non-title keys', async () => {
|
||||
const config = makeConfig()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('')
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
|
||||
})
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalledWith('rename_note', expect.anything())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { useTabManagement } from './useTabManagement'
|
||||
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
|
||||
interface NewEntryParams {
|
||||
path: string
|
||||
@@ -60,15 +61,21 @@ function isTypeKey(key: string): boolean {
|
||||
return k === 'type' || k === 'is_a'
|
||||
}
|
||||
|
||||
/** Check if a frontmatter key represents the note title. */
|
||||
function isTitleKey(key: string): boolean {
|
||||
return key.toLowerCase().replace(/\s+/g, '_') === 'title'
|
||||
}
|
||||
|
||||
async function performRename(
|
||||
path: string,
|
||||
newTitle: string,
|
||||
vaultPath: string,
|
||||
oldTitle?: string,
|
||||
): Promise<RenameResult> {
|
||||
if (isTauri()) {
|
||||
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle })
|
||||
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle, oldTitle: oldTitle ?? null })
|
||||
}
|
||||
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle })
|
||||
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle, old_title: oldTitle ?? null })
|
||||
}
|
||||
|
||||
function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry {
|
||||
@@ -100,7 +107,13 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
|
||||
}
|
||||
|
||||
export function slugify(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const result = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
return result || 'untitled'
|
||||
}
|
||||
|
||||
/** Check if a note's filename doesn't match the slug of its current title. */
|
||||
export function needsRenameOnSave(title: string, filename: string): boolean {
|
||||
return `${slugify(title)}.md` !== filename
|
||||
}
|
||||
|
||||
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
|
||||
@@ -117,14 +130,8 @@ export function generateUntitledName(entries: VaultEntry[], type: string, pendin
|
||||
return title
|
||||
}
|
||||
|
||||
export function entryMatchesTarget(e: VaultEntry, targetLower: string, targetAsWords: string): boolean {
|
||||
if (e.title.toLowerCase() === targetLower) return true
|
||||
if (e.aliases.some((a) => a.toLowerCase() === targetLower)) return true
|
||||
const pathStem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
if (pathStem.toLowerCase() === targetLower) return true
|
||||
const fileStem = e.filename.replace(/\.md$/, '')
|
||||
if (fileStem.toLowerCase() === targetLower.split('/').pop()) return true
|
||||
return e.title.toLowerCase() === targetAsWords
|
||||
export function entryMatchesTarget(e: VaultEntry, target: string): boolean {
|
||||
return resolveEntry([e], target) === e
|
||||
}
|
||||
|
||||
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
|
||||
@@ -256,9 +263,7 @@ function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => voi
|
||||
}
|
||||
|
||||
function findWikilinkTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
|
||||
const targetLower = target.toLowerCase()
|
||||
const targetAsWords = target.split('/').pop()?.replace(/-/g, ' ').toLowerCase() ?? targetLower
|
||||
return entries.find((e) => entryMatchesTarget(e, targetLower, targetAsWords))
|
||||
return resolveEntry(entries, target)
|
||||
}
|
||||
|
||||
/** Navigate to a wikilink target, logging a warning if not found. */
|
||||
@@ -392,18 +397,42 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
|
||||
try {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
} catch (err) {
|
||||
console.error('Failed to create note:', err)
|
||||
setToastMessage('Failed to create note')
|
||||
}
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
/** Create a note with the given title, open it in a tab, and persist to disk.
|
||||
* Returns true on success, false on failure (shows toast on error). */
|
||||
const handleCreateNoteForRelationship = useCallback(async (title: string): Promise<boolean> => {
|
||||
const template = resolveTemplate(entries, 'Note')
|
||||
const resolved = resolveNewNote(title, 'Note', config.vaultPath, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
try {
|
||||
await persistNewNote(resolved.entry.path, resolved.content)
|
||||
config.onNewNotePersisted?.()
|
||||
return true
|
||||
} catch {
|
||||
handleCloseTab(resolved.entry.path)
|
||||
removeEntry(resolved.entry.path)
|
||||
setToastMessage('Failed to create note — disk write error')
|
||||
return false
|
||||
}
|
||||
}, [entries, openTabWithContent, addEntry, handleCloseTab, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
/** Close tab and discard entry+unsaved state if the note was never persisted. */
|
||||
const handleCloseTabWithCleanup = useCallback((path: string) => {
|
||||
@@ -439,9 +468,9 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const result = await performRename(path, newTitle, vaultPath)
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
const entry = entries.find((e) => e.path === path)
|
||||
const result = await performRename(path, newTitle, vaultPath, entry?.title)
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path)
|
||||
const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path).map(t => t.entry.path)
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t))
|
||||
@@ -461,25 +490,49 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleNavigateWikilink,
|
||||
handleCreateNote,
|
||||
handleCreateNoteImmediate,
|
||||
handleCreateNoteForRelationship,
|
||||
handleOpenDailyNote,
|
||||
handleCreateType,
|
||||
createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
await runFrontmatterOp('update', path, key, value)
|
||||
if (isTitleKey(key) && typeof value === 'string' && value !== '') {
|
||||
try {
|
||||
const oldTitle = tabsRef.current.find(t => t.entry.path === path)?.entry.title
|
||||
const result = await performRename(path, value, config.vaultPath, oldTitle)
|
||||
if (result.new_path !== path) {
|
||||
const newFilename = result.new_path.split('/').pop() ?? ''
|
||||
config.replaceEntry?.(path, { path: result.new_path, filename: newFilename, title: value } as Partial<VaultEntry> & { path: string })
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
setTabs(prev => prev.map(t => t.entry.path === path
|
||||
? { entry: { ...t.entry, path: result.new_path, filename: newFilename, title: value }, content: newContent }
|
||||
: t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
const otherTabPaths = tabsRef.current.filter(t => t.entry.path !== path && t.entry.path !== result.new_path).map(t => t.entry.path)
|
||||
await reloadTabsAfterRename(otherTabPaths, updateTabContent)
|
||||
}
|
||||
setToastMessage(renameToastMessage(result.updated_files))
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note after title change:', err)
|
||||
}
|
||||
}
|
||||
if (isTypeKey(key) && typeof value === 'string' && value !== '') {
|
||||
try {
|
||||
const result = await performMoveToTypeFolder(config.vaultPath, path, value)
|
||||
if (result.moved) {
|
||||
const entry = entries.find(e => e.path === path)
|
||||
if (entry) {
|
||||
const newFilename = result.new_path.split('/').pop() ?? entry.filename
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
config.replaceEntry?.(path, { ...entry, path: result.new_path, filename: newFilename })
|
||||
setTabs(prev => prev.map(t => t.entry.path === path
|
||||
? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: newContent }
|
||||
: t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
}
|
||||
const newFilename = result.new_path.split('/').pop() ?? ''
|
||||
// Update the vault entry with the new path. Only pass the changed
|
||||
// fields — avoid spreading a stale closure entry which would revert
|
||||
// the isA update that runFrontmatterOp already applied.
|
||||
config.replaceEntry?.(path, { path: result.new_path, filename: newFilename } as Partial<VaultEntry> & { path: string })
|
||||
// Preserve the tab content already set by runFrontmatterOp.
|
||||
// Re-reading from disk via loadNoteContent is unnecessary (the move
|
||||
// does not change content) and dangerous: if the path collides or a
|
||||
// stale cache intervenes it could return a different note's content.
|
||||
setTabs(prev => prev.map(t => t.entry.path === path
|
||||
? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: t.content }
|
||||
: t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
const folder = result.new_path.split('/').slice(-2, -1)[0] ?? ''
|
||||
setToastMessage(`Note moved to ${folder}/`)
|
||||
}
|
||||
@@ -487,7 +540,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
console.error('Failed to move note to type folder:', err)
|
||||
}
|
||||
}
|
||||
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage]),
|
||||
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
|
||||
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
|
||||
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleRenameNote,
|
||||
|
||||
@@ -84,4 +84,32 @@ describe('useRawMode', () => {
|
||||
// Cannot activate raw mode without an active tab path
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('calls onBeforeRawEnd when deactivating raw mode', async () => {
|
||||
const onBeforeRawEnd = vi.fn()
|
||||
const { result } = renderHook(
|
||||
({ path }) => useRawMode({ activeTabPath: path, onFlushPending, onBeforeRawEnd }),
|
||||
{ initialProps: { path: '/note.md' } },
|
||||
)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(onBeforeRawEnd).toHaveBeenCalledOnce()
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('does not call onBeforeRawEnd when activating raw mode', async () => {
|
||||
const onBeforeRawEnd = vi.fn()
|
||||
const { result } = renderHook(
|
||||
({ path }) => useRawMode({ activeTabPath: path, onFlushPending, onBeforeRawEnd }),
|
||||
{ initialProps: { path: '/note.md' } },
|
||||
)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(onBeforeRawEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,9 @@ interface UseRawModeParams {
|
||||
activeTabPath: string | null
|
||||
/** Flush pending WYSIWYG edits to disk before entering raw mode. */
|
||||
onFlushPending?: () => Promise<boolean>
|
||||
/** Called synchronously before raw mode is deactivated, so the caller can
|
||||
* flush any debounced raw-editor content into tab state. */
|
||||
onBeforeRawEnd?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -11,19 +14,20 @@ interface UseRawModeParams {
|
||||
* Raw mode is automatically inactive when the active tab changes,
|
||||
* because rawMode is derived from whether the stored path matches the current tab.
|
||||
*/
|
||||
export function useRawMode({ activeTabPath, onFlushPending }: UseRawModeParams) {
|
||||
export function useRawMode({ activeTabPath, onFlushPending, onBeforeRawEnd }: UseRawModeParams) {
|
||||
// Track which path has raw mode active — automatically deactivates on tab switch
|
||||
const [rawActivePath, setRawActivePath] = useState<string | null>(null)
|
||||
const rawMode = rawActivePath !== null && rawActivePath === activeTabPath
|
||||
|
||||
const handleToggleRaw = useCallback(async () => {
|
||||
if (rawMode) {
|
||||
onBeforeRawEnd?.()
|
||||
setRawActivePath(null)
|
||||
} else {
|
||||
await onFlushPending?.()
|
||||
setRawActivePath(activeTabPath)
|
||||
}
|
||||
}, [rawMode, activeTabPath, onFlushPending])
|
||||
}, [rawMode, activeTabPath, onFlushPending, onBeforeRawEnd])
|
||||
|
||||
return { rawMode, handleToggleRaw }
|
||||
}
|
||||
|
||||
@@ -439,6 +439,111 @@ describe('useTabManagement', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('closed tab history', () => {
|
||||
it('handleCloseTab records the closed tab in history', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
|
||||
act(() => { result.current.handleCloseTab('/vault/a.md') })
|
||||
|
||||
expect(result.current.closedTabHistory.canReopen).toBe(true)
|
||||
})
|
||||
|
||||
it('handleReopenClosedTab reopens the last closed tab', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
act(() => { result.current.handleCloseTab('/vault/b.md') })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReopenClosedTab()
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(2)
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
})
|
||||
|
||||
it('close 3 tabs then reopen all 3 in correct LIFO order', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/c.md', title: 'C' }))
|
||||
})
|
||||
|
||||
// Close C, B, A
|
||||
act(() => { result.current.handleCloseTab('/vault/c.md') })
|
||||
act(() => { result.current.handleCloseTab('/vault/b.md') })
|
||||
act(() => { result.current.handleCloseTab('/vault/a.md') })
|
||||
|
||||
expect(result.current.tabs).toHaveLength(0)
|
||||
|
||||
// Reopen: should get A first (last closed), then B, then C
|
||||
await act(async () => { await result.current.handleReopenClosedTab() })
|
||||
expect(result.current.activeTabPath).toBe('/vault/a.md')
|
||||
|
||||
await act(async () => { await result.current.handleReopenClosedTab() })
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
|
||||
await act(async () => { await result.current.handleReopenClosedTab() })
|
||||
expect(result.current.activeTabPath).toBe('/vault/c.md')
|
||||
|
||||
expect(result.current.tabs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('does nothing when history is empty', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReopenClosedTab()
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(0)
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
})
|
||||
|
||||
it('does not duplicate tab if note is already open', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/a.md', title: 'A' }))
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
// Close B
|
||||
act(() => { result.current.handleCloseTab('/vault/b.md') })
|
||||
|
||||
// Manually reopen B via handleSelectNote
|
||||
await act(async () => {
|
||||
await result.current.handleSelectNote(makeEntry({ path: '/vault/b.md', title: 'B' }))
|
||||
})
|
||||
|
||||
// Now try to reopen from history — B is already open, should just switch
|
||||
await act(async () => {
|
||||
await result.current.handleReopenClosedTab()
|
||||
})
|
||||
|
||||
expect(result.current.tabs).toHaveLength(2)
|
||||
expect(result.current.activeTabPath).toBe('/vault/b.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rapid switching safety', () => {
|
||||
it('only activates the last note when switching rapidly', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useClosedTabHistory } from './useClosedTabHistory'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -137,6 +138,7 @@ export function useTabManagement() {
|
||||
const tabsRef = useRef(tabs)
|
||||
useEffect(() => { tabsRef.current = tabs })
|
||||
const handleCloseTabRef = useRef<(path: string) => void>(() => {})
|
||||
const closedTabHistory = useClosedTabHistory()
|
||||
|
||||
// Sequence counter for rapid-switch safety: only the latest navigation wins.
|
||||
// Prevents stale content from an earlier click appearing after a later click.
|
||||
@@ -151,11 +153,13 @@ export function useTabManagement() {
|
||||
|
||||
const handleCloseTab = useCallback((path: string) => {
|
||||
setTabs((prev) => {
|
||||
const idx = prev.findIndex((t) => t.entry.path === path)
|
||||
if (idx !== -1) closedTabHistory.push(path, idx, prev[idx].entry)
|
||||
const next = prev.filter((t) => t.entry.path !== path)
|
||||
if (path === activeTabPathRef.current) { setActiveTabPath(resolveNextActiveTab(prev, path)) }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
}, [closedTabHistory])
|
||||
useEffect(() => { handleCloseTabRef.current = handleCloseTab })
|
||||
|
||||
const handleSwitchTab = useCallback((path: string) => { setActiveTabPath(path) }, [])
|
||||
@@ -180,6 +184,18 @@ export function useTabManagement() {
|
||||
if (navSeqRef.current === seq) setActiveTabPath(entry.path)
|
||||
}, [handleSelectNote])
|
||||
|
||||
const handleReopenClosedTab = useCallback(async () => {
|
||||
const closed = closedTabHistory.pop()
|
||||
if (!closed) return
|
||||
// If tab is already open, just switch to it
|
||||
if (isTabOpen(tabsRef.current, closed.path)) {
|
||||
setActiveTabPath(closed.path)
|
||||
return
|
||||
}
|
||||
// Reopen using the stored VaultEntry — loads fresh content from disk
|
||||
await handleSelectNote(closed.entry)
|
||||
}, [closedTabHistory, handleSelectNote])
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
setTabs([])
|
||||
setActiveTabPath(null)
|
||||
@@ -209,6 +225,8 @@ export function useTabManagement() {
|
||||
handleSwitchTab,
|
||||
handleReorderTabs,
|
||||
handleReplaceActiveTab,
|
||||
handleReopenClosedTab,
|
||||
closeAllTabs,
|
||||
closedTabHistory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,55 @@ describe('useThemeManager', () => {
|
||||
expect(result.current.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('notifyThemeSaved updates CSS vars when active theme is saved', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, allContent)
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
|
||||
})
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
|
||||
const updatedContent = `---
|
||||
type: Theme
|
||||
Description: Light theme
|
||||
background: "#1a1a2e"
|
||||
foreground: "#e0e0e0"
|
||||
primary: "#155DFF"
|
||||
sidebar: "#2a2a3e"
|
||||
text-primary: "#e0e0e0"
|
||||
---
|
||||
|
||||
# Default Theme
|
||||
`
|
||||
act(() => {
|
||||
result.current.notifyThemeSaved(THEME_PATH_DEFAULT, updatedContent)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#1a1a2e')
|
||||
})
|
||||
expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#2a2a3e')
|
||||
})
|
||||
|
||||
it('notifyThemeSaved is a no-op for non-active theme path', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, allContent)
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
|
||||
})
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
|
||||
act(() => {
|
||||
result.current.notifyThemeSaved(THEME_PATH_DARK, DARK_THEME_CONTENT)
|
||||
})
|
||||
|
||||
// Background should still be the default theme's white, not dark
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
})
|
||||
|
||||
it('calls ensure_vault_themes on mount with vaultPath', async () => {
|
||||
renderHook(() => useThemeManager('/vault', entries, allContent))
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -124,6 +124,8 @@ export interface ThemeManager {
|
||||
reloadThemes: () => Promise<void>
|
||||
/** Update a single frontmatter property on the active theme note. */
|
||||
updateThemeProperty: (key: string, value: string) => Promise<void>
|
||||
/** Notify that the active theme note was saved with new content (live-reload on Cmd+S). */
|
||||
notifyThemeSaved: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
/** Manages loading and persisting the active theme path from vault settings. */
|
||||
@@ -275,6 +277,10 @@ export function useThemeManager(
|
||||
|
||||
const reloadThemes = useCallback(async () => { await reload() }, [reload])
|
||||
|
||||
const notifyThemeSaved = useCallback((path: string, content: string) => {
|
||||
if (path === activeThemeId) setCachedThemeContent(content)
|
||||
}, [activeThemeId])
|
||||
|
||||
const updateThemeProperty = useCallback(async (key: string, value: string) => {
|
||||
if (!activeThemeId) return
|
||||
try {
|
||||
@@ -290,6 +296,6 @@ export function useThemeManager(
|
||||
return {
|
||||
themes, activeThemeId, activeTheme,
|
||||
activeThemeContent: cachedThemeContent,
|
||||
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty,
|
||||
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty, notifyThemeSaved,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
--accent-teal-light: rgba(49, 151, 149, 0.1);
|
||||
--accent-pink: #D53F8C;
|
||||
--accent-pink-light: rgba(213, 63, 140, 0.1);
|
||||
--accent-gray: #718096;
|
||||
--accent-gray-light: rgba(113, 128, 150, 0.1);
|
||||
--border-primary: #E9E9E7;
|
||||
--border-subtle: #E9E9E7;
|
||||
--border-input: #E9E9E7;
|
||||
|
||||
@@ -735,19 +735,147 @@ Essential reading for anyone building distributed systems. Covers replication, p
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/default.md': `---
|
||||
type: Theme
|
||||
title: Default
|
||||
primary: "#155DFF"
|
||||
Description: Light theme with warm, paper-like tones
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
sidebar: "#F7F6F3"
|
||||
border: "#E9E9E7"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#EBEBEA"
|
||||
secondary-foreground: "#37352F"
|
||||
muted: "#F0F0EF"
|
||||
muted-foreground: "#9B9A97"
|
||||
accent: "#F0F7FF"
|
||||
accent-foreground: "#0A3B8F"
|
||||
muted-foreground: "#787774"
|
||||
accent: "#EBEBEA"
|
||||
accent-foreground: "#37352F"
|
||||
destructive: "#E03E3E"
|
||||
border: "#E9E9E7"
|
||||
input: "#E9E9E7"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#F7F6F3"
|
||||
sidebar-foreground: "#37352F"
|
||||
sidebar-border: "#E9E9E7"
|
||||
sidebar-accent: "#EBEBEA"
|
||||
text-primary: "#37352F"
|
||||
text-secondary: "#787774"
|
||||
text-tertiary: "#B4B4B4"
|
||||
text-muted: "#B4B4B4"
|
||||
text-heading: "#37352F"
|
||||
bg-primary: "#FFFFFF"
|
||||
bg-card: "#FFFFFF"
|
||||
bg-sidebar: "#F7F6F3"
|
||||
bg-hover: "#EBEBEA"
|
||||
bg-hover-subtle: "#F0F0EF"
|
||||
bg-selected: "#E8F4FE"
|
||||
border-primary: "#E9E9E7"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#E03E3E"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF14"
|
||||
accent-green-light: "#00B38B14"
|
||||
accent-purple-light: "#A932FF14"
|
||||
accent-red-light: "#E03E3E14"
|
||||
accent-yellow-light: "#F0B10014"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14
|
||||
line-height-base: 1.6
|
||||
font-size-base: 14px
|
||||
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
editor-font-size: 15px
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720px
|
||||
editor-padding-horizontal: 40px
|
||||
editor-padding-vertical: 20px
|
||||
editor-paragraph-spacing: 8px
|
||||
headings-h1-font-size: 32px
|
||||
headings-h1-font-weight: 700
|
||||
headings-h1-line-height: 1.2
|
||||
headings-h1-margin-top: 32px
|
||||
headings-h1-margin-bottom: 12px
|
||||
headings-h1-color: "var(--text-heading)"
|
||||
headings-h1-letter-spacing: -0.5px
|
||||
headings-h2-font-size: 27px
|
||||
headings-h2-font-weight: 600
|
||||
headings-h2-line-height: 1.4
|
||||
headings-h2-margin-top: 28px
|
||||
headings-h2-margin-bottom: 10px
|
||||
headings-h2-color: "var(--text-heading)"
|
||||
headings-h2-letter-spacing: -0.5px
|
||||
headings-h3-font-size: 20px
|
||||
headings-h3-font-weight: 600
|
||||
headings-h3-line-height: 1.4
|
||||
headings-h3-margin-top: 24px
|
||||
headings-h3-margin-bottom: 8px
|
||||
headings-h3-color: "var(--text-heading)"
|
||||
headings-h3-letter-spacing: -0.5px
|
||||
headings-h4-font-size: 20px
|
||||
headings-h4-font-weight: 600
|
||||
headings-h4-line-height: 1.4
|
||||
headings-h4-margin-top: 20px
|
||||
headings-h4-margin-bottom: 6px
|
||||
headings-h4-color: "var(--text-heading)"
|
||||
headings-h4-letter-spacing: 0px
|
||||
lists-bullet-size: 28px
|
||||
lists-bullet-color: "#177bfd"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
checkboxes-size: 18px
|
||||
checkboxes-border-radius: 3px
|
||||
checkboxes-checked-color: "var(--accent-blue)"
|
||||
checkboxes-unchecked-border-color: "var(--text-muted)"
|
||||
checkboxes-gap: 8px
|
||||
inline-styles-bold-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
inline-styles-code-font-size: 14px
|
||||
inline-styles-code-background-color: "var(--bg-hover-subtle)"
|
||||
inline-styles-code-padding-horizontal: 4px
|
||||
inline-styles-code-padding-vertical: 2px
|
||||
inline-styles-code-border-radius: 3px
|
||||
inline-styles-code-color: "var(--text-secondary)"
|
||||
inline-styles-link-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
inline-styles-wikilink-color: "var(--accent-blue)"
|
||||
inline-styles-wikilink-text-decoration: none
|
||||
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
|
||||
inline-styles-wikilink-cursor: pointer
|
||||
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
code-blocks-font-size: 13px
|
||||
code-blocks-line-height: 1.5
|
||||
code-blocks-background-color: "var(--bg-card)"
|
||||
code-blocks-padding-horizontal: 16px
|
||||
code-blocks-padding-vertical: 12px
|
||||
code-blocks-border-radius: 6px
|
||||
code-blocks-margin-vertical: 12px
|
||||
blockquote-border-left-width: 3px
|
||||
blockquote-border-left-color: "var(--accent-blue)"
|
||||
blockquote-padding-left: 16px
|
||||
blockquote-margin-vertical: 12px
|
||||
blockquote-color: "var(--text-secondary)"
|
||||
blockquote-font-style: italic
|
||||
table-border-color: "var(--border-primary)"
|
||||
table-header-background: "var(--bg-card)"
|
||||
table-cell-padding-horizontal: 12px
|
||||
table-cell-padding-vertical: 8px
|
||||
table-font-size: 14px
|
||||
horizontal-rule-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
colors-background: "var(--bg-primary)"
|
||||
colors-text: "var(--text-primary)"
|
||||
colors-text-secondary: "var(--text-secondary)"
|
||||
colors-text-muted: "var(--text-muted)"
|
||||
colors-heading: "var(--text-heading)"
|
||||
colors-accent: "var(--accent-blue)"
|
||||
colors-selection: "var(--bg-selected)"
|
||||
colors-cursor: "var(--text-primary)"
|
||||
---
|
||||
|
||||
# Default
|
||||
@@ -756,19 +884,147 @@ Light theme with warm, paper-like tones.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/dark.md': `---
|
||||
type: Theme
|
||||
title: Dark
|
||||
primary: "#155DFF"
|
||||
Description: Dark variant with deep navy tones
|
||||
background: "#0f0f1a"
|
||||
foreground: "#e0e0e0"
|
||||
sidebar: "#1a1a2e"
|
||||
border: "#2a2a4a"
|
||||
card: "#16162a"
|
||||
popover: "#1e1e3a"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#2a2a4a"
|
||||
secondary-foreground: "#e0e0e0"
|
||||
muted: "#1e1e3a"
|
||||
muted-foreground: "#6b6b8a"
|
||||
accent: "#1a2a4a"
|
||||
accent-foreground: "#8ab4ff"
|
||||
muted-foreground: "#888888"
|
||||
accent: "#2a2a4a"
|
||||
accent-foreground: "#e0e0e0"
|
||||
destructive: "#f44336"
|
||||
border: "#2a2a4a"
|
||||
input: "#2a2a4a"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#1a1a2e"
|
||||
sidebar-foreground: "#e0e0e0"
|
||||
sidebar-border: "#2a2a4a"
|
||||
sidebar-accent: "#2a2a4a"
|
||||
text-primary: "#e0e0e0"
|
||||
text-secondary: "#888888"
|
||||
text-tertiary: "#666666"
|
||||
text-muted: "#666666"
|
||||
text-heading: "#e0e0e0"
|
||||
bg-primary: "#0f0f1a"
|
||||
bg-card: "#16162a"
|
||||
bg-sidebar: "#1a1a2e"
|
||||
bg-hover: "#2a2a4a"
|
||||
bg-hover-subtle: "#1e1e3a"
|
||||
bg-selected: "#155DFF22"
|
||||
border-primary: "#2a2a4a"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#f44336"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF33"
|
||||
accent-green-light: "#00B38B33"
|
||||
accent-purple-light: "#A932FF33"
|
||||
accent-red-light: "#f4433633"
|
||||
accent-yellow-light: "#F0B10033"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14
|
||||
line-height-base: 1.6
|
||||
font-size-base: 14px
|
||||
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
editor-font-size: 15px
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720px
|
||||
editor-padding-horizontal: 40px
|
||||
editor-padding-vertical: 20px
|
||||
editor-paragraph-spacing: 8px
|
||||
headings-h1-font-size: 32px
|
||||
headings-h1-font-weight: 700
|
||||
headings-h1-line-height: 1.2
|
||||
headings-h1-margin-top: 32px
|
||||
headings-h1-margin-bottom: 12px
|
||||
headings-h1-color: "var(--text-heading)"
|
||||
headings-h1-letter-spacing: -0.5px
|
||||
headings-h2-font-size: 27px
|
||||
headings-h2-font-weight: 600
|
||||
headings-h2-line-height: 1.4
|
||||
headings-h2-margin-top: 28px
|
||||
headings-h2-margin-bottom: 10px
|
||||
headings-h2-color: "var(--text-heading)"
|
||||
headings-h2-letter-spacing: -0.5px
|
||||
headings-h3-font-size: 20px
|
||||
headings-h3-font-weight: 600
|
||||
headings-h3-line-height: 1.4
|
||||
headings-h3-margin-top: 24px
|
||||
headings-h3-margin-bottom: 8px
|
||||
headings-h3-color: "var(--text-heading)"
|
||||
headings-h3-letter-spacing: -0.5px
|
||||
headings-h4-font-size: 20px
|
||||
headings-h4-font-weight: 600
|
||||
headings-h4-line-height: 1.4
|
||||
headings-h4-margin-top: 20px
|
||||
headings-h4-margin-bottom: 6px
|
||||
headings-h4-color: "var(--text-heading)"
|
||||
headings-h4-letter-spacing: 0px
|
||||
lists-bullet-size: 28px
|
||||
lists-bullet-color: "#155DFF"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
checkboxes-size: 18px
|
||||
checkboxes-border-radius: 3px
|
||||
checkboxes-checked-color: "var(--accent-blue)"
|
||||
checkboxes-unchecked-border-color: "var(--text-muted)"
|
||||
checkboxes-gap: 8px
|
||||
inline-styles-bold-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
inline-styles-code-font-size: 14px
|
||||
inline-styles-code-background-color: "var(--bg-hover-subtle)"
|
||||
inline-styles-code-padding-horizontal: 4px
|
||||
inline-styles-code-padding-vertical: 2px
|
||||
inline-styles-code-border-radius: 3px
|
||||
inline-styles-code-color: "var(--text-secondary)"
|
||||
inline-styles-link-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
inline-styles-wikilink-color: "var(--accent-blue)"
|
||||
inline-styles-wikilink-text-decoration: none
|
||||
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
|
||||
inline-styles-wikilink-cursor: pointer
|
||||
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
code-blocks-font-size: 13px
|
||||
code-blocks-line-height: 1.5
|
||||
code-blocks-background-color: "var(--bg-card)"
|
||||
code-blocks-padding-horizontal: 16px
|
||||
code-blocks-padding-vertical: 12px
|
||||
code-blocks-border-radius: 6px
|
||||
code-blocks-margin-vertical: 12px
|
||||
blockquote-border-left-width: 3px
|
||||
blockquote-border-left-color: "var(--accent-blue)"
|
||||
blockquote-padding-left: 16px
|
||||
blockquote-margin-vertical: 12px
|
||||
blockquote-color: "var(--text-secondary)"
|
||||
blockquote-font-style: italic
|
||||
table-border-color: "var(--border-primary)"
|
||||
table-header-background: "var(--bg-card)"
|
||||
table-cell-padding-horizontal: 12px
|
||||
table-cell-padding-vertical: 8px
|
||||
table-font-size: 14px
|
||||
horizontal-rule-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
colors-background: "var(--bg-primary)"
|
||||
colors-text: "var(--text-primary)"
|
||||
colors-text-secondary: "var(--text-secondary)"
|
||||
colors-text-muted: "var(--text-muted)"
|
||||
colors-heading: "var(--text-heading)"
|
||||
colors-accent: "var(--accent-blue)"
|
||||
colors-selection: "var(--bg-selected)"
|
||||
colors-cursor: "var(--text-primary)"
|
||||
---
|
||||
|
||||
# Dark
|
||||
@@ -777,19 +1033,147 @@ Dark variant with deep navy tones.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/minimal.md': `---
|
||||
type: Theme
|
||||
title: Minimal
|
||||
primary: "#000000"
|
||||
Description: High contrast, minimal chrome
|
||||
background: "#FAFAFA"
|
||||
foreground: "#111111"
|
||||
sidebar: "#F5F5F5"
|
||||
border: "#E0E0E0"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#000000"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#F0F0F0"
|
||||
secondary-foreground: "#111111"
|
||||
muted: "#F5F5F5"
|
||||
muted-foreground: "#888888"
|
||||
muted-foreground: "#666666"
|
||||
accent: "#F0F0F0"
|
||||
accent-foreground: "#000000"
|
||||
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: 13
|
||||
line-height-base: 1.5
|
||||
font-size-base: 13px
|
||||
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
editor-font-size: 15px
|
||||
editor-line-height: 1.6
|
||||
editor-max-width: 680px
|
||||
editor-padding-horizontal: 40px
|
||||
editor-padding-vertical: 20px
|
||||
editor-paragraph-spacing: 8px
|
||||
headings-h1-font-size: 32px
|
||||
headings-h1-font-weight: 700
|
||||
headings-h1-line-height: 1.2
|
||||
headings-h1-margin-top: 32px
|
||||
headings-h1-margin-bottom: 12px
|
||||
headings-h1-color: "var(--text-heading)"
|
||||
headings-h1-letter-spacing: -0.5px
|
||||
headings-h2-font-size: 27px
|
||||
headings-h2-font-weight: 600
|
||||
headings-h2-line-height: 1.4
|
||||
headings-h2-margin-top: 28px
|
||||
headings-h2-margin-bottom: 10px
|
||||
headings-h2-color: "var(--text-heading)"
|
||||
headings-h2-letter-spacing: -0.5px
|
||||
headings-h3-font-size: 20px
|
||||
headings-h3-font-weight: 600
|
||||
headings-h3-line-height: 1.4
|
||||
headings-h3-margin-top: 24px
|
||||
headings-h3-margin-bottom: 8px
|
||||
headings-h3-color: "var(--text-heading)"
|
||||
headings-h3-letter-spacing: -0.5px
|
||||
headings-h4-font-size: 20px
|
||||
headings-h4-font-weight: 600
|
||||
headings-h4-line-height: 1.4
|
||||
headings-h4-margin-top: 20px
|
||||
headings-h4-margin-bottom: 6px
|
||||
headings-h4-color: "var(--text-heading)"
|
||||
headings-h4-letter-spacing: 0px
|
||||
lists-bullet-size: 28px
|
||||
lists-bullet-color: "#000000"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
checkboxes-size: 18px
|
||||
checkboxes-border-radius: 3px
|
||||
checkboxes-checked-color: "var(--accent-blue)"
|
||||
checkboxes-unchecked-border-color: "var(--text-muted)"
|
||||
checkboxes-gap: 8px
|
||||
inline-styles-bold-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
inline-styles-code-font-size: 14px
|
||||
inline-styles-code-background-color: "var(--bg-hover-subtle)"
|
||||
inline-styles-code-padding-horizontal: 4px
|
||||
inline-styles-code-padding-vertical: 2px
|
||||
inline-styles-code-border-radius: 3px
|
||||
inline-styles-code-color: "var(--text-secondary)"
|
||||
inline-styles-link-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
inline-styles-wikilink-color: "var(--accent-blue)"
|
||||
inline-styles-wikilink-text-decoration: none
|
||||
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
|
||||
inline-styles-wikilink-cursor: pointer
|
||||
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
code-blocks-font-size: 13px
|
||||
code-blocks-line-height: 1.5
|
||||
code-blocks-background-color: "var(--bg-card)"
|
||||
code-blocks-padding-horizontal: 16px
|
||||
code-blocks-padding-vertical: 12px
|
||||
code-blocks-border-radius: 6px
|
||||
code-blocks-margin-vertical: 12px
|
||||
blockquote-border-left-width: 3px
|
||||
blockquote-border-left-color: "var(--accent-blue)"
|
||||
blockquote-padding-left: 16px
|
||||
blockquote-margin-vertical: 12px
|
||||
blockquote-color: "var(--text-secondary)"
|
||||
blockquote-font-style: italic
|
||||
table-border-color: "var(--border-primary)"
|
||||
table-header-background: "var(--bg-card)"
|
||||
table-cell-padding-horizontal: 12px
|
||||
table-cell-padding-vertical: 8px
|
||||
table-font-size: 14px
|
||||
horizontal-rule-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
colors-background: "var(--bg-primary)"
|
||||
colors-text: "var(--text-primary)"
|
||||
colors-text-secondary: "var(--text-secondary)"
|
||||
colors-text-muted: "var(--text-muted)"
|
||||
colors-heading: "var(--text-heading)"
|
||||
colors-accent: "var(--accent-blue)"
|
||||
colors-selection: "var(--bg-selected)"
|
||||
colors-cursor: "var(--text-primary)"
|
||||
---
|
||||
|
||||
# Minimal
|
||||
|
||||
@@ -826,6 +826,34 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
properties: {},
|
||||
},
|
||||
// --- Custom type documents ---
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/config.md',
|
||||
filename: 'config.md',
|
||||
title: 'Config',
|
||||
isA: 'Type',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 30,
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 300,
|
||||
snippet: 'Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.',
|
||||
wordCount: 25,
|
||||
relationships: {},
|
||||
icon: 'gear-six',
|
||||
color: 'gray',
|
||||
order: 90,
|
||||
sidebarLabel: 'Config',
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/type/recipe.md',
|
||||
filename: 'recipe.md',
|
||||
@@ -883,6 +911,36 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
properties: {},
|
||||
},
|
||||
// --- Instances of custom types ---
|
||||
{
|
||||
path: '/Users/luca/Laputa/config/agents.md',
|
||||
filename: 'agents.md',
|
||||
title: 'Agent Instructions',
|
||||
isA: 'Config',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 5,
|
||||
createdAt: now - 86400 * 30,
|
||||
fileSize: 1200,
|
||||
snippet: 'Vault instructions for AI agents. Defines how tools and integrations interact with this vault.',
|
||||
wordCount: 200,
|
||||
relationships: {
|
||||
'Type': ['[[type/config]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/recipe/pasta-carbonara.md',
|
||||
filename: 'pasta-carbonara.md',
|
||||
|
||||
@@ -114,7 +114,13 @@ const mockThemes: ThemeFile[] = [
|
||||
|
||||
let mockDeviceFlowPollCount = 0
|
||||
|
||||
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string }) {
|
||||
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) {
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldTitle = args.old_title ?? oldEntry?.title ?? ''
|
||||
if (oldTitle === args.new_title) {
|
||||
return { new_path: args.old_path, updated_files: 0 }
|
||||
}
|
||||
|
||||
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
|
||||
const slug = args.new_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
|
||||
@@ -123,9 +129,6 @@ function handleRenameNote(args: { vault_path: string; old_path: string; new_titl
|
||||
|
||||
delete MOCK_CONTENT[args.old_path]
|
||||
MOCK_CONTENT[newPath] = newContent
|
||||
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldTitle = oldEntry?.title ?? ''
|
||||
let updatedFiles = 0
|
||||
if (oldTitle) {
|
||||
const pattern = new RegExp(`\\[\\[${oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\|[^\\]]*?)?\\]\\]`, 'g')
|
||||
@@ -227,7 +230,16 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
if (currentFolder === slug) return { new_path: args.note_path, updated_links: 0, moved: false }
|
||||
const filename = args.note_path.split('/').pop() ?? ''
|
||||
const vaultPath = args.vault_path.replace(/\/$/, '')
|
||||
const newPath = `${vaultPath}/${slug}/${filename}`
|
||||
// Handle collisions: append -2, -3, etc. if the target path already exists
|
||||
// (mirrors the Rust unique_dest_path logic).
|
||||
let newPath = `${vaultPath}/${slug}/${filename}`
|
||||
if (newPath in MOCK_CONTENT && newPath !== args.note_path) {
|
||||
const stem = filename.replace(/\.md$/, '')
|
||||
const ext = filename.endsWith('.md') ? '.md' : ''
|
||||
let counter = 2
|
||||
while (`${vaultPath}/${slug}/${stem}-${counter}${ext}` in MOCK_CONTENT) counter++
|
||||
newPath = `${vaultPath}/${slug}/${stem}-${counter}${ext}`
|
||||
}
|
||||
const content = MOCK_CONTENT[args.note_path] ?? ''
|
||||
delete MOCK_CONTENT[args.note_path]
|
||||
MOCK_CONTENT[newPath] = content
|
||||
@@ -249,6 +261,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
clone_repo: (args: { url: string; local_path: string }) => `Cloned to ${args.local_path}`,
|
||||
purge_trash: () => [],
|
||||
delete_note: (args: { path: string }) => args.path,
|
||||
batch_delete_notes: (args: { paths: string[] }) => args.paths,
|
||||
empty_trash: () => [],
|
||||
migrate_is_a_to_type: () => 0,
|
||||
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
|
||||
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,
|
||||
@@ -317,23 +331,153 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme'
|
||||
const path = `${args.vaultPath}/theme/${slug}.md`
|
||||
MOCK_CONTENT[path] = `---
|
||||
type: Theme
|
||||
title: ${displayName}
|
||||
primary: "#155DFF"
|
||||
Is A: Theme
|
||||
Description: ${displayName} theme
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
sidebar: "#F7F6F3"
|
||||
border: "#E9E9E7"
|
||||
card: "#FFFFFF"
|
||||
popover: "#FFFFFF"
|
||||
primary: "#155DFF"
|
||||
primary-foreground: "#FFFFFF"
|
||||
secondary: "#EBEBEA"
|
||||
secondary-foreground: "#37352F"
|
||||
muted: "#F0F0EF"
|
||||
muted-foreground: "#9B9A97"
|
||||
accent: "#F0F7FF"
|
||||
accent-foreground: "#0A3B8F"
|
||||
muted-foreground: "#787774"
|
||||
accent: "#EBEBEA"
|
||||
accent-foreground: "#37352F"
|
||||
destructive: "#E03E3E"
|
||||
border: "#E9E9E7"
|
||||
input: "#E9E9E7"
|
||||
ring: "#155DFF"
|
||||
sidebar: "#F7F6F3"
|
||||
sidebar-foreground: "#37352F"
|
||||
sidebar-border: "#E9E9E7"
|
||||
sidebar-accent: "#EBEBEA"
|
||||
text-primary: "#37352F"
|
||||
text-secondary: "#787774"
|
||||
text-tertiary: "#B4B4B4"
|
||||
text-muted: "#B4B4B4"
|
||||
text-heading: "#37352F"
|
||||
bg-primary: "#FFFFFF"
|
||||
bg-card: "#FFFFFF"
|
||||
bg-sidebar: "#F7F6F3"
|
||||
bg-hover: "#EBEBEA"
|
||||
bg-hover-subtle: "#F0F0EF"
|
||||
bg-selected: "#E8F4FE"
|
||||
border-primary: "#E9E9E7"
|
||||
accent-blue: "#155DFF"
|
||||
accent-green: "#00B38B"
|
||||
accent-orange: "#D9730D"
|
||||
accent-red: "#E03E3E"
|
||||
accent-purple: "#A932FF"
|
||||
accent-yellow: "#F0B100"
|
||||
accent-blue-light: "#155DFF14"
|
||||
accent-green-light: "#00B38B14"
|
||||
accent-purple-light: "#A932FF14"
|
||||
accent-red-light: "#E03E3E14"
|
||||
accent-yellow-light: "#F0B10014"
|
||||
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
font-size-base: 14
|
||||
line-height-base: 1.6
|
||||
font-size-base: 14px
|
||||
editor-font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
editor-font-size: 15px
|
||||
editor-line-height: 1.5
|
||||
editor-max-width: 720px
|
||||
editor-padding-horizontal: 40px
|
||||
editor-padding-vertical: 20px
|
||||
editor-paragraph-spacing: 8px
|
||||
headings-h1-font-size: 32px
|
||||
headings-h1-font-weight: 700
|
||||
headings-h1-line-height: 1.2
|
||||
headings-h1-margin-top: 32px
|
||||
headings-h1-margin-bottom: 12px
|
||||
headings-h1-color: "var(--text-heading)"
|
||||
headings-h1-letter-spacing: -0.5px
|
||||
headings-h2-font-size: 27px
|
||||
headings-h2-font-weight: 600
|
||||
headings-h2-line-height: 1.4
|
||||
headings-h2-margin-top: 28px
|
||||
headings-h2-margin-bottom: 10px
|
||||
headings-h2-color: "var(--text-heading)"
|
||||
headings-h2-letter-spacing: -0.5px
|
||||
headings-h3-font-size: 20px
|
||||
headings-h3-font-weight: 600
|
||||
headings-h3-line-height: 1.4
|
||||
headings-h3-margin-top: 24px
|
||||
headings-h3-margin-bottom: 8px
|
||||
headings-h3-color: "var(--text-heading)"
|
||||
headings-h3-letter-spacing: -0.5px
|
||||
headings-h4-font-size: 20px
|
||||
headings-h4-font-weight: 600
|
||||
headings-h4-line-height: 1.4
|
||||
headings-h4-margin-top: 20px
|
||||
headings-h4-margin-bottom: 6px
|
||||
headings-h4-color: "var(--text-heading)"
|
||||
headings-h4-letter-spacing: 0px
|
||||
lists-bullet-size: 28px
|
||||
lists-bullet-color: "#177bfd"
|
||||
lists-indent-size: 24px
|
||||
lists-item-spacing: 4px
|
||||
lists-padding-left: 8px
|
||||
lists-bullet-gap: 6px
|
||||
checkboxes-size: 18px
|
||||
checkboxes-border-radius: 3px
|
||||
checkboxes-checked-color: "var(--accent-blue)"
|
||||
checkboxes-unchecked-border-color: "var(--text-muted)"
|
||||
checkboxes-gap: 8px
|
||||
inline-styles-bold-font-weight: 700
|
||||
inline-styles-bold-color: "var(--text-primary)"
|
||||
inline-styles-italic-font-style: italic
|
||||
inline-styles-italic-color: "var(--text-primary)"
|
||||
inline-styles-strikethrough-color: "var(--text-tertiary)"
|
||||
inline-styles-strikethrough-text-decoration: line-through
|
||||
inline-styles-code-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
inline-styles-code-font-size: 14px
|
||||
inline-styles-code-background-color: "var(--bg-hover-subtle)"
|
||||
inline-styles-code-padding-horizontal: 4px
|
||||
inline-styles-code-padding-vertical: 2px
|
||||
inline-styles-code-border-radius: 3px
|
||||
inline-styles-code-color: "var(--text-secondary)"
|
||||
inline-styles-link-color: "var(--accent-blue)"
|
||||
inline-styles-link-text-decoration: underline
|
||||
inline-styles-wikilink-color: "var(--accent-blue)"
|
||||
inline-styles-wikilink-text-decoration: none
|
||||
inline-styles-wikilink-border-bottom: "1px dotted currentColor"
|
||||
inline-styles-wikilink-cursor: pointer
|
||||
code-blocks-font-family: "'SF Mono', 'Fira Code', monospace"
|
||||
code-blocks-font-size: 13px
|
||||
code-blocks-line-height: 1.5
|
||||
code-blocks-background-color: "var(--bg-card)"
|
||||
code-blocks-padding-horizontal: 16px
|
||||
code-blocks-padding-vertical: 12px
|
||||
code-blocks-border-radius: 6px
|
||||
code-blocks-margin-vertical: 12px
|
||||
blockquote-border-left-width: 3px
|
||||
blockquote-border-left-color: "var(--accent-blue)"
|
||||
blockquote-padding-left: 16px
|
||||
blockquote-margin-vertical: 12px
|
||||
blockquote-color: "var(--text-secondary)"
|
||||
blockquote-font-style: italic
|
||||
table-border-color: "var(--border-primary)"
|
||||
table-header-background: "var(--bg-card)"
|
||||
table-cell-padding-horizontal: 12px
|
||||
table-cell-padding-vertical: 8px
|
||||
table-font-size: 14px
|
||||
horizontal-rule-color: "var(--border-primary)"
|
||||
horizontal-rule-margin-vertical: 24px
|
||||
horizontal-rule-thickness: 1px
|
||||
colors-background: "var(--bg-primary)"
|
||||
colors-text: "var(--text-primary)"
|
||||
colors-text-secondary: "var(--text-secondary)"
|
||||
colors-text-muted: "var(--text-muted)"
|
||||
colors-heading: "var(--text-heading)"
|
||||
colors-accent: "var(--accent-blue)"
|
||||
colors-selection: "var(--bg-selected)"
|
||||
colors-cursor: "var(--text-primary)"
|
||||
---
|
||||
|
||||
# ${displayName}
|
||||
|
||||
A custom ${displayName} theme for Laputa.
|
||||
`
|
||||
const now = Date.now() / 1000
|
||||
MOCK_ENTRIES.push({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Vault API detection and proxy for browser dev mode.
|
||||
* When a local vault API server is running, routes read commands through it
|
||||
* instead of returning hardcoded mock data.
|
||||
* When a local vault API server is running, routes read and write commands
|
||||
* through it instead of returning hardcoded mock data.
|
||||
*/
|
||||
|
||||
let vaultApiAvailable: boolean | null = null
|
||||
@@ -18,25 +18,60 @@ async function checkVaultApi(): Promise<boolean> {
|
||||
return vaultApiAvailable
|
||||
}
|
||||
|
||||
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => string | null> = {
|
||||
list_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
reload_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` : null,
|
||||
get_note_content: (args) => args.path ? `/api/vault/content?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
get_all_content: (args) => args.path ? `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
interface VaultApiRequest {
|
||||
url: string
|
||||
method?: string
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
/** Tracks last vault path for commands that don't receive it as an argument. */
|
||||
let lastVaultPath: string | null = null
|
||||
|
||||
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => VaultApiRequest | null> = {
|
||||
list_vault: (args) => {
|
||||
if (args.path) lastVaultPath = args.path as string
|
||||
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}` } : null
|
||||
},
|
||||
reload_vault: (args) => {
|
||||
if (args.path) lastVaultPath = args.path as string
|
||||
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` } : null
|
||||
},
|
||||
reload_vault_entry: (args) =>
|
||||
args.path ? { url: `/api/vault/entry?path=${encodeURIComponent(args.path as string)}` } : null,
|
||||
get_note_content: (args) =>
|
||||
args.path ? { url: `/api/vault/content?path=${encodeURIComponent(args.path as string)}` } : null,
|
||||
get_all_content: (args) =>
|
||||
args.path ? { url: `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` } : null,
|
||||
save_note_content: (args) =>
|
||||
args.path ? { url: '/api/vault/save', method: 'POST', body: { path: args.path, content: args.content } } : null,
|
||||
rename_note: (args) =>
|
||||
args.old_path ? { url: '/api/vault/rename', method: 'POST', body: { vault_path: args.vault_path, old_path: args.old_path, new_title: args.new_title } } : null,
|
||||
delete_note: (args) =>
|
||||
args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null,
|
||||
search_vault: (args) => {
|
||||
const q = args.query as string
|
||||
if (!q || !lastVaultPath) return null
|
||||
return { url: `/api/vault/search?vault_path=${encodeURIComponent(lastVaultPath)}&query=${encodeURIComponent(q)}&mode=${encodeURIComponent((args.mode as string) || 'all')}` }
|
||||
},
|
||||
}
|
||||
|
||||
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
|
||||
const available = await checkVaultApi()
|
||||
if (!available) return undefined
|
||||
|
||||
const urlBuilder = VAULT_API_COMMANDS[cmd]
|
||||
if (!urlBuilder || !args) return undefined
|
||||
const requestBuilder = VAULT_API_COMMANDS[cmd]
|
||||
if (!requestBuilder || !args) return undefined
|
||||
|
||||
const url = urlBuilder(args)
|
||||
if (!url) return undefined
|
||||
const request = requestBuilder(args)
|
||||
if (!request) return undefined
|
||||
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
const fetchOpts: RequestInit = { method: request.method || 'GET' }
|
||||
if (request.body) {
|
||||
fetchOpts.headers = { 'Content-Type': 'application/json' }
|
||||
fetchOpts.body = JSON.stringify(request.body)
|
||||
}
|
||||
const res = await fetch(request.url, fetchOpts)
|
||||
if (!res.ok) return undefined
|
||||
const data = await res.json()
|
||||
return (cmd === 'get_note_content' ? data.content : data) as T
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
import { wikilinkTarget } from './wikilink'
|
||||
import { wikilinkTarget, resolveEntry } from './wikilink'
|
||||
import { splitFrontmatter } from './wikilinks'
|
||||
|
||||
/** Extract only the body text from raw file content (strips YAML frontmatter). */
|
||||
@@ -13,16 +13,10 @@ function extractBody(rawContent: string): string {
|
||||
return body.trim()
|
||||
}
|
||||
|
||||
/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem. */
|
||||
/** Resolve a link target string to a VaultEntry by matching title, aliases, or filename stem.
|
||||
* Delegates to the unified resolveEntry for consistent matching. */
|
||||
export function resolveTarget(target: string, entries: VaultEntry[]): VaultEntry | undefined {
|
||||
const lower = target.toLowerCase()
|
||||
return entries.find(e => {
|
||||
if (e.title.toLowerCase() === lower) return true
|
||||
if (e.aliases.some(a => a.toLowerCase() === lower)) return true
|
||||
const stem = e.filename.replace(/\.md$/, '')
|
||||
if (stem.toLowerCase() === lower) return true
|
||||
return false
|
||||
})
|
||||
return resolveEntry(entries, target)
|
||||
}
|
||||
|
||||
/** Collect first-degree linked notes from the active entry. */
|
||||
|
||||
102
src/utils/compact-markdown.test.ts
Normal file
102
src/utils/compact-markdown.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { compactMarkdown } from './compact-markdown'
|
||||
|
||||
describe('compactMarkdown', () => {
|
||||
it('collapses blank lines between bullet list items (tight list)', () => {
|
||||
const input = '* Item one\n\n* Item two\n\n* Item three\n'
|
||||
expect(compactMarkdown(input)).toBe('* Item one\n* Item two\n* Item three\n')
|
||||
})
|
||||
|
||||
it('collapses blank lines between dash list items', () => {
|
||||
const input = '- Item one\n\n- Item two\n\n- Item three\n'
|
||||
expect(compactMarkdown(input)).toBe('- Item one\n- Item two\n- Item three\n')
|
||||
})
|
||||
|
||||
it('collapses blank lines between numbered list items', () => {
|
||||
const input = '1. First\n\n2. Second\n\n3. Third\n'
|
||||
expect(compactMarkdown(input)).toBe('1. First\n2. Second\n3. Third\n')
|
||||
})
|
||||
|
||||
it('removes extra blank line after heading', () => {
|
||||
const input = '## Personal\n\n* Back on track\n\n* Good health\n'
|
||||
expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track\n* Good health\n')
|
||||
})
|
||||
|
||||
it('preserves single blank line between heading and content', () => {
|
||||
const input = '## Title\n\nSome paragraph text.\n'
|
||||
expect(compactMarkdown(input)).toBe('## Title\n\nSome paragraph text.\n')
|
||||
})
|
||||
|
||||
it('collapses multiple blank lines after heading to one', () => {
|
||||
const input = '## Title\n\n\n\nSome paragraph text.\n'
|
||||
expect(compactMarkdown(input)).toBe('## Title\n\nSome paragraph text.\n')
|
||||
})
|
||||
|
||||
it('preserves blank lines between paragraphs', () => {
|
||||
const input = 'First paragraph.\n\nSecond paragraph.\n'
|
||||
expect(compactMarkdown(input)).toBe('First paragraph.\n\nSecond paragraph.\n')
|
||||
})
|
||||
|
||||
it('preserves blank lines inside fenced code blocks', () => {
|
||||
const input = '```\nline one\n\nline two\n\nline three\n```\n'
|
||||
expect(compactMarkdown(input)).toBe('```\nline one\n\nline two\n\nline three\n```\n')
|
||||
})
|
||||
|
||||
it('preserves blank lines inside fenced code blocks with language', () => {
|
||||
const input = '```js\nconst a = 1\n\nconst b = 2\n```\n'
|
||||
expect(compactMarkdown(input)).toBe('```js\nconst a = 1\n\nconst b = 2\n```\n')
|
||||
})
|
||||
|
||||
it('does not add trailing blank lines', () => {
|
||||
const input = '## Title\n\nText.\n\n\n'
|
||||
expect(compactMarkdown(input)).toBe('## Title\n\nText.\n')
|
||||
})
|
||||
|
||||
it('handles the exact bug scenario from the issue', () => {
|
||||
const input = '## Personal\n\n* Back on track with Flavia\n\n* Good health vitals in place\n\n'
|
||||
expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track with Flavia\n* Good health vitals in place\n')
|
||||
})
|
||||
|
||||
it('handles heading followed immediately by list (no blank line)', () => {
|
||||
const input = '## Title\n* Item one\n\n* Item two\n'
|
||||
expect(compactMarkdown(input)).toBe('## Title\n* Item one\n* Item two\n')
|
||||
})
|
||||
|
||||
it('handles mixed content: heading, paragraph, list', () => {
|
||||
const input = '# Title\n\nSome intro.\n\n* Item one\n\n* Item two\n\nConclusion.\n'
|
||||
expect(compactMarkdown(input)).toBe('# Title\n\nSome intro.\n\n* Item one\n* Item two\n\nConclusion.\n')
|
||||
})
|
||||
|
||||
it('preserves empty input', () => {
|
||||
expect(compactMarkdown('')).toBe('')
|
||||
})
|
||||
|
||||
it('preserves single line', () => {
|
||||
expect(compactMarkdown('Hello\n')).toBe('Hello\n')
|
||||
})
|
||||
|
||||
it('collapses 3+ consecutive blank lines to one blank line', () => {
|
||||
const input = 'Paragraph one.\n\n\n\nParagraph two.\n'
|
||||
expect(compactMarkdown(input)).toBe('Paragraph one.\n\nParagraph two.\n')
|
||||
})
|
||||
|
||||
it('handles list after code block', () => {
|
||||
const input = '```\ncode\n```\n\n* Item one\n\n* Item two\n'
|
||||
expect(compactMarkdown(input)).toBe('```\ncode\n```\n\n* Item one\n* Item two\n')
|
||||
})
|
||||
|
||||
it('handles nested list items', () => {
|
||||
const input = '* Parent\n\n * Child one\n\n * Child two\n'
|
||||
expect(compactMarkdown(input)).toBe('* Parent\n * Child one\n * Child two\n')
|
||||
})
|
||||
|
||||
it('handles checklist items', () => {
|
||||
const input = '* [ ] Todo one\n\n* [ ] Todo two\n\n* [x] Done\n'
|
||||
expect(compactMarkdown(input)).toBe('* [ ] Todo one\n* [ ] Todo two\n* [x] Done\n')
|
||||
})
|
||||
|
||||
it('handles blockquotes normally', () => {
|
||||
const input = '> Quote line one\n\n> Quote line two\n'
|
||||
expect(compactMarkdown(input)).toBe('> Quote line one\n\n> Quote line two\n')
|
||||
})
|
||||
})
|
||||
82
src/utils/compact-markdown.ts
Normal file
82
src/utils/compact-markdown.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Post-process BlockNote's blocksToMarkdownLossy output to produce
|
||||
* standard-convention Markdown:
|
||||
* - Tight lists (no blank lines between consecutive list items)
|
||||
* - No runs of 3+ blank lines (collapsed to one blank line)
|
||||
* - No trailing blank lines
|
||||
* - Code block content is never modified
|
||||
*/
|
||||
export function compactMarkdown(md: string): string {
|
||||
if (!md) return md
|
||||
|
||||
const lines = md.split('\n')
|
||||
const result: string[] = []
|
||||
let inCodeBlock = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// Track fenced code blocks — never modify content inside them
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
inCodeBlock = !inCodeBlock
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip blank lines that sit between two list items (tight list rule)
|
||||
if (line.trim() === '') {
|
||||
if (isBlankBetweenListItems(lines, i)) continue
|
||||
// Collapse runs of 3+ blank lines to a single blank line
|
||||
if (isExcessiveBlankLine(lines, i)) continue
|
||||
result.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
result.push(line)
|
||||
}
|
||||
|
||||
// Trim trailing blank lines, keep exactly one trailing newline
|
||||
while (result.length > 0 && result[result.length - 1].trim() === '') {
|
||||
result.pop()
|
||||
}
|
||||
if (result.length > 0) result.push('')
|
||||
|
||||
return result.join('\n')
|
||||
}
|
||||
|
||||
const LIST_RE = /^(\s*)([-*+]|\d+\.)\s/
|
||||
|
||||
/** True if this blank line sits between two list items (including nested) */
|
||||
function isBlankBetweenListItems(lines: string[], idx: number): boolean {
|
||||
const prev = findPrevNonBlank(lines, idx)
|
||||
const next = findNextNonBlank(lines, idx)
|
||||
if (prev === null || next === null) return false
|
||||
return LIST_RE.test(lines[prev]) && LIST_RE.test(lines[next])
|
||||
}
|
||||
|
||||
/** True if this blank line is part of a run of 2+ consecutive blank lines
|
||||
* (i.e. would create 3+ newlines in a row — collapse to just one blank line) */
|
||||
function isExcessiveBlankLine(lines: string[], idx: number): boolean {
|
||||
// Keep the first blank line in a run, skip subsequent ones
|
||||
if (idx > 0 && lines[idx - 1].trim() === '') return true
|
||||
return false
|
||||
}
|
||||
|
||||
function findPrevNonBlank(lines: string[], idx: number): number | null {
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (lines[i].trim() !== '') return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findNextNonBlank(lines: string[], idx: number): number | null {
|
||||
for (let i = idx + 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() !== '') return i
|
||||
}
|
||||
return null
|
||||
}
|
||||
46
src/utils/frontmatter.test.ts
Normal file
46
src/utils/frontmatter.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseFrontmatter } from './frontmatter'
|
||||
|
||||
describe('parseFrontmatter', () => {
|
||||
describe('boolean-like Yes/No values', () => {
|
||||
it('parses Archived: Yes as true', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: Yes\n---\nBody')
|
||||
expect(fm['Archived']).toBe(true)
|
||||
})
|
||||
|
||||
it('parses Archived: No as false', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: No\n---\nBody')
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
|
||||
it('parses Trashed: Yes as true', () => {
|
||||
const fm = parseFrontmatter('---\nTrashed: Yes\n---\nBody')
|
||||
expect(fm['Trashed']).toBe(true)
|
||||
})
|
||||
|
||||
it('parses Trashed: No as false', () => {
|
||||
const fm = parseFrontmatter('---\nTrashed: No\n---\nBody')
|
||||
expect(fm['Trashed']).toBe(false)
|
||||
})
|
||||
|
||||
it('parses yes (lowercase) as true', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: yes\n---\nBody')
|
||||
expect(fm['Archived']).toBe(true)
|
||||
})
|
||||
|
||||
it('parses no (lowercase) as false', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: no\n---\nBody')
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
|
||||
it('still parses true as true', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: true\n---\nBody')
|
||||
expect(fm['Archived']).toBe(true)
|
||||
})
|
||||
|
||||
it('still parses false as false', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: false\n---\nBody')
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,8 +23,9 @@ function parseInlineArray(value: string): FrontmatterValue {
|
||||
|
||||
function parseScalar(value: string): FrontmatterValue {
|
||||
const clean = unquote(value)
|
||||
if (clean.toLowerCase() === 'true') return true
|
||||
if (clean.toLowerCase() === 'false') return false
|
||||
const lower = clean.toLowerCase()
|
||||
if (lower === 'true' || lower === 'yes') return true
|
||||
if (lower === 'false' || lower === 'no') return false
|
||||
return clean
|
||||
}
|
||||
|
||||
|
||||
27
src/utils/iconRegistry.test.ts
Normal file
27
src/utils/iconRegistry.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveIcon, ICON_OPTIONS } from './iconRegistry'
|
||||
import { FileText, GearSix, CookingPot } from '@phosphor-icons/react'
|
||||
|
||||
describe('resolveIcon', () => {
|
||||
it('returns FileText for null', () => {
|
||||
expect(resolveIcon(null)).toBe(FileText)
|
||||
})
|
||||
|
||||
it('returns FileText for unknown icon name', () => {
|
||||
expect(resolveIcon('nonexistent-icon')).toBe(FileText)
|
||||
})
|
||||
|
||||
it('resolves gear-six to GearSix', () => {
|
||||
expect(resolveIcon('gear-six')).toBe(GearSix)
|
||||
})
|
||||
|
||||
it('resolves cooking-pot to CookingPot', () => {
|
||||
expect(resolveIcon('cooking-pot')).toBe(CookingPot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ICON_OPTIONS', () => {
|
||||
it('includes gear-six', () => {
|
||||
expect(ICON_OPTIONS.some((o) => o.name === 'gear-six')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Door, Drop, Envelope, Eye, Eyeglasses, Factory, Fan, Farm, Feather, File,
|
||||
FileCode, FileText, FilmReel, Fingerprint, Fire, FirstAid, Fish, Flag, Flame, Flashlight,
|
||||
Flask, Flower, FlowerLotus, Folder, FolderOpen, Football, ForkKnife, Function, Funnel, GameController,
|
||||
Gauge, Gavel, Gear, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
|
||||
Gauge, Gavel, Gear, GearSix, Ghost, Gift, GitBranch, Globe, GraduationCap, Guitar, Hammer,
|
||||
Hand, Handshake, Headphones, Headset, Heart, Heartbeat, Horse, Hospital, Hourglass, House,
|
||||
IceCream, IdentificationBadge, Image, Infinity as InfinityIcon, Island, Joystick, Kanban, Key, Keyboard, Knife,
|
||||
Ladder, Lamp, Laptop, Leaf, Lifebuoy, Lightbulb, Lighthouse, Lightning, Link, List,
|
||||
@@ -174,6 +174,7 @@ export const ICON_OPTIONS: IconEntry[] = [
|
||||
{ name: 'gauge', Icon: Gauge },
|
||||
{ name: 'gavel', Icon: Gavel },
|
||||
{ name: 'gear', Icon: Gear },
|
||||
{ name: 'gear-six', Icon: GearSix },
|
||||
{ name: 'ghost', Icon: Ghost },
|
||||
{ name: 'gift', Icon: Gift },
|
||||
{ name: 'git-branch', Icon: GitBranch },
|
||||
|
||||
65
src/utils/sidebarSections.ts
Normal file
65
src/utils/sidebarSections.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pure functions for building sidebar section groups from vault entries.
|
||||
* Extracted from Sidebar.tsx for testability.
|
||||
*/
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { SectionGroup } from '../components/SidebarParts'
|
||||
import { resolveIcon } from './iconRegistry'
|
||||
import { pluralizeType } from '../hooks/useCommandRegistry'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, StackSimple,
|
||||
} from '@phosphor-icons/react'
|
||||
|
||||
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]))
|
||||
|
||||
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
|
||||
export 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 */
|
||||
export 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 */
|
||||
export function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
const activeTypes = collectActiveTypes(entries)
|
||||
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
|
||||
}
|
||||
|
||||
export 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)
|
||||
})
|
||||
}
|
||||
|
||||
export { BUILT_IN_SECTION_GROUPS }
|
||||
@@ -42,6 +42,34 @@ describe('attachClickHandlers', () => {
|
||||
)
|
||||
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/x.md' })
|
||||
})
|
||||
|
||||
it('uses path|title target when candidates have duplicate titles', () => {
|
||||
const insertWikilink = vi.fn()
|
||||
const candidates = [
|
||||
{ title: 'Status Update', aliases: [], group: 'Project', entryTitle: 'Status Update', path: '/vault/project/status-update.md' },
|
||||
{ title: 'Status Update', aliases: [], group: 'Journal', entryTitle: 'Status Update', path: '/vault/journal/status-update.md' },
|
||||
]
|
||||
|
||||
const result = attachClickHandlers(candidates, insertWikilink)
|
||||
|
||||
result[0].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('project/status-update|Status Update')
|
||||
result[1].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('journal/status-update|Status Update')
|
||||
})
|
||||
|
||||
it('uses title-only target when titles are unique', () => {
|
||||
const insertWikilink = vi.fn()
|
||||
const candidates = [
|
||||
{ title: 'Alpha', aliases: [], group: 'Note', entryTitle: 'Alpha', path: '/vault/note/alpha.md' },
|
||||
{ title: 'Beta', aliases: [], group: 'Note', entryTitle: 'Beta', path: '/vault/note/beta.md' },
|
||||
]
|
||||
|
||||
const result = attachClickHandlers(candidates, insertWikilink)
|
||||
|
||||
result[0].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('Alpha')
|
||||
})
|
||||
})
|
||||
|
||||
describe('enrichSuggestionItems', () => {
|
||||
|
||||
@@ -16,14 +16,31 @@ interface BaseSuggestionItem {
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Add onItemClick to raw suggestion candidates */
|
||||
/** Build a vault-relative path target with pipe display: "type/slug|Title" */
|
||||
function buildPathTarget(item: BaseSuggestionItem): string {
|
||||
const parts = item.path.split('/')
|
||||
const vaultRelPath = parts.slice(-2).join('/').replace(/\.md$/, '')
|
||||
return `${vaultRelPath}|${item.entryTitle}`
|
||||
}
|
||||
|
||||
/** Add onItemClick to raw suggestion candidates.
|
||||
* When multiple candidates share the same title, inserts a path-based
|
||||
* target with pipe syntax so the wikilink uniquely identifies the note. */
|
||||
export function attachClickHandlers(
|
||||
candidates: BaseSuggestionItem[],
|
||||
insertWikilink: (target: string) => void,
|
||||
) {
|
||||
const titleCounts = new Map<string, number>()
|
||||
for (const item of candidates) {
|
||||
titleCounts.set(item.entryTitle, (titleCounts.get(item.entryTitle) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return candidates.map(item => ({
|
||||
...item,
|
||||
onItemClick: () => insertWikilink(item.entryTitle),
|
||||
onItemClick: () => {
|
||||
const isDuplicate = (titleCounts.get(item.entryTitle) ?? 0) > 1
|
||||
insertWikilink(isDuplicate ? buildPathTarget(item) : item.entryTitle)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { buildThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from './themeSchema'
|
||||
import type { ThemeProperty } from './themeSchema'
|
||||
|
||||
@@ -124,6 +126,41 @@ describe('buildThemeSchema', () => {
|
||||
expect(fontStyle.options).toContain('normal')
|
||||
expect(fontStyle.options).toContain('italic')
|
||||
})
|
||||
|
||||
it('every var(--xxx) in EditorTheme.css has a matching default in theme schema or base UI', () => {
|
||||
const cssPath = resolve(__dirname, '../components/EditorTheme.css')
|
||||
const css = readFileSync(cssPath, 'utf-8')
|
||||
|
||||
// Extract all var(--xxx) references (first arg only, ignore fallbacks)
|
||||
const varRegex = /var\(--([a-z0-9-]+)/g
|
||||
const usedVars = new Set<string>()
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = varRegex.exec(css)) !== null) {
|
||||
usedVars.add(match[1])
|
||||
}
|
||||
|
||||
// Collect all CSS var names from the schema
|
||||
const schemaVars = new Set<string>()
|
||||
for (const section of schema) {
|
||||
for (const prop of section.properties) schemaVars.add(prop.cssVar)
|
||||
for (const sub of section.subsections) {
|
||||
for (const prop of sub.properties) schemaVars.add(prop.cssVar)
|
||||
}
|
||||
}
|
||||
|
||||
// Base UI color vars set by the theme color system (not in theme.json schema)
|
||||
const baseUIVars = new Set([
|
||||
'border-primary', 'bg-primary', 'bg-card', 'bg-hover-subtle', 'bg-selected',
|
||||
'text-primary', 'text-secondary', 'text-muted', 'text-heading', 'text-tertiary',
|
||||
'accent-blue',
|
||||
])
|
||||
|
||||
for (const varName of usedVars) {
|
||||
expect(
|
||||
schemaVars.has(varName) || baseUIVars.has(varName),
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatValueForFrontmatter', () => {
|
||||
|
||||
@@ -28,6 +28,10 @@ describe('getTypeColor', () => {
|
||||
it('ignores invalid custom color key', () => {
|
||||
expect(getTypeColor('Project', 'invalid')).toBe('var(--accent-red)')
|
||||
})
|
||||
|
||||
it('uses gray custom color key', () => {
|
||||
expect(getTypeColor('Config', 'gray')).toBe('var(--accent-gray)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTypeLightColor', () => {
|
||||
@@ -47,6 +51,10 @@ describe('getTypeLightColor', () => {
|
||||
it('uses custom color key for light variant', () => {
|
||||
expect(getTypeLightColor('Recipe', 'purple')).toBe('var(--accent-purple-light)')
|
||||
})
|
||||
|
||||
it('uses gray custom color key for light variant', () => {
|
||||
expect(getTypeLightColor('Config', 'gray')).toBe('var(--accent-gray-light)')
|
||||
})
|
||||
})
|
||||
|
||||
const baseEntry: VaultEntry = {
|
||||
@@ -54,20 +62,22 @@ const baseEntry: VaultEntry = {
|
||||
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, template: null, sort: null, outgoingLinks: [],
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null, sort: null,
|
||||
view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
}
|
||||
|
||||
describe('buildTypeEntryMap', () => {
|
||||
it('indexes Type entries by title', () => {
|
||||
it('indexes Type entries by title and lowercase', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{ ...baseEntry, title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' },
|
||||
{ ...baseEntry, title: 'My Note', isA: 'Note' },
|
||||
{ ...baseEntry, title: 'Evergreen', isA: 'Type', color: 'green', icon: 'leaf' },
|
||||
]
|
||||
const map = buildTypeEntryMap(entries)
|
||||
expect(Object.keys(map)).toEqual(['Recipe', 'Evergreen'])
|
||||
expect(map['Recipe'].color).toBe('orange')
|
||||
expect(map['recipe'].color).toBe('orange')
|
||||
expect(map['Evergreen'].icon).toBe('leaf')
|
||||
expect(map['evergreen'].icon).toBe('leaf')
|
||||
})
|
||||
|
||||
it('returns empty map when no Type entries exist', () => {
|
||||
@@ -76,4 +86,15 @@ describe('buildTypeEntryMap', () => {
|
||||
]
|
||||
expect(buildTypeEntryMap(entries)).toEqual({})
|
||||
})
|
||||
|
||||
it('preserves sidebarLabel in type entry via exact and lowercase keys', () => {
|
||||
const entries: VaultEntry[] = [
|
||||
{ ...baseEntry, title: 'Config', isA: 'Type', icon: 'gear-six', color: 'gray', sidebarLabel: 'Config' },
|
||||
]
|
||||
const map = buildTypeEntryMap(entries)
|
||||
expect(map['Config'].sidebarLabel).toBe('Config')
|
||||
expect(map['config'].sidebarLabel).toBe('Config')
|
||||
expect(map['config'].icon).toBe('gear-six')
|
||||
expect(map['config'].color).toBe('gray')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,18 @@
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
/** Builds a map from type name → Type document entry (for custom color/icon lookup) */
|
||||
/** Builds a map from type name → Type document entry (for custom color/icon lookup).
|
||||
* Stores both original title and lowercase version so lookups work regardless
|
||||
* of whether instances use `isA: Config` or `isA: config`. */
|
||||
export function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
for (const e of entries) { if (e.isA === 'Type') map[e.title] = e }
|
||||
for (const e of entries) {
|
||||
if (e.isA === 'Type') {
|
||||
map[e.title] = e
|
||||
const lower = e.title.toLowerCase()
|
||||
if (lower !== e.title) map[lower] = e
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
@@ -47,6 +55,7 @@ export const ACCENT_COLORS: { key: string; label: string; css: string; cssLight:
|
||||
{ key: 'purple', label: 'Purple', css: 'var(--accent-purple)', cssLight: 'var(--accent-purple-light)' },
|
||||
{ key: 'teal', label: 'Teal', css: 'var(--accent-teal)', cssLight: 'var(--accent-teal-light)' },
|
||||
{ key: 'pink', label: 'Pink', css: 'var(--accent-pink)', cssLight: 'var(--accent-pink-light)' },
|
||||
{ key: 'gray', label: 'Gray', css: 'var(--accent-gray)', cssLight: 'var(--accent-gray-light)' },
|
||||
]
|
||||
|
||||
const COLOR_KEY_TO_CSS: Record<string, string> = Object.fromEntries(
|
||||
|
||||
88
src/utils/wikilink.test.ts
Normal file
88
src/utils/wikilink.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { wikilinkTarget, wikilinkDisplay, resolveEntry } from './wikilink'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
return {
|
||||
path: '/vault/note.md', filename: 'note.md', title: 'Note', isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null, template: null,
|
||||
sort: null, outgoingLinks: [], sidebarLabel: null, view: null, visible: null,
|
||||
properties: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('wikilinkTarget', () => {
|
||||
it('strips brackets', () => {
|
||||
expect(wikilinkTarget('[[foo]]')).toBe('foo')
|
||||
})
|
||||
it('returns target before pipe', () => {
|
||||
expect(wikilinkTarget('[[path|display]]')).toBe('path')
|
||||
})
|
||||
it('handles bare text without brackets', () => {
|
||||
expect(wikilinkTarget('just text')).toBe('just text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('wikilinkDisplay', () => {
|
||||
it('returns text after pipe', () => {
|
||||
expect(wikilinkDisplay('[[path|My Title]]')).toBe('My Title')
|
||||
})
|
||||
it('humanises slug when no pipe', () => {
|
||||
expect(wikilinkDisplay('[[my-note]]')).toBe('My Note')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEntry', () => {
|
||||
const alice = makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice', isA: 'Person', aliases: ['Alice Smith'] })
|
||||
const bob = makeEntry({ path: '/vault/person/bob.md', filename: 'bob.md', title: 'Bob', isA: 'Person' })
|
||||
const project = makeEntry({ path: '/vault/project/my-project.md', filename: 'my-project.md', title: 'My Project', isA: 'Project' })
|
||||
const entries = [alice, bob, project]
|
||||
|
||||
it('matches by title (case-insensitive)', () => {
|
||||
expect(resolveEntry(entries, 'alice')).toBe(alice)
|
||||
expect(resolveEntry(entries, 'ALICE')).toBe(alice)
|
||||
expect(resolveEntry(entries, 'Alice')).toBe(alice)
|
||||
})
|
||||
|
||||
it('matches by alias (case-insensitive)', () => {
|
||||
expect(resolveEntry(entries, 'alice smith')).toBe(alice)
|
||||
expect(resolveEntry(entries, 'Alice Smith')).toBe(alice)
|
||||
})
|
||||
|
||||
it('matches by filename stem (case-insensitive)', () => {
|
||||
expect(resolveEntry(entries, 'my-project')).toBe(project)
|
||||
expect(resolveEntry(entries, 'My-Project')).toBe(project)
|
||||
})
|
||||
|
||||
it('matches by relative path suffix (type/slug)', () => {
|
||||
expect(resolveEntry(entries, 'person/alice')).toBe(alice)
|
||||
expect(resolveEntry(entries, 'project/my-project')).toBe(project)
|
||||
})
|
||||
|
||||
it('handles pipe syntax: uses target part for lookup', () => {
|
||||
expect(resolveEntry(entries, 'person/alice|Alice S.')).toBe(alice)
|
||||
expect(resolveEntry(entries, 'Alice|A')).toBe(alice)
|
||||
})
|
||||
|
||||
it('returns undefined for non-existent target', () => {
|
||||
expect(resolveEntry(entries, 'Does Not Exist')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty entries', () => {
|
||||
expect(resolveEntry([], 'Alice')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('matches by filename stem from last segment of path target', () => {
|
||||
// If target is "person/alice", the last segment "alice" should match filename stem
|
||||
expect(resolveEntry(entries, 'person/alice')).toBe(alice)
|
||||
})
|
||||
|
||||
it('matches title-as-words from kebab-case target', () => {
|
||||
// "my-project" → "my project" should match title "My Project"
|
||||
expect(resolveEntry(entries, 'my-project')).toBe(project)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Utility functions for parsing wikilink syntax: [[target|display]] */
|
||||
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
/** Extracts the target path from a wikilink reference (strips [[ ]] and display text). */
|
||||
export function wikilinkTarget(ref: string): string {
|
||||
const inner = ref.replace(/^\[\[|\]\]$/g, '')
|
||||
@@ -15,3 +17,26 @@ export function wikilinkDisplay(ref: string): string {
|
||||
const last = inner.split('/').pop() ?? inner
|
||||
return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
|
||||
* Handles pipe syntax, case-insensitive matching, title/alias/filename/path lookup.
|
||||
*/
|
||||
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
|
||||
const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
|
||||
const keyLower = key.toLowerCase()
|
||||
const suffix = '/' + key + '.md'
|
||||
const lastSegment = key.split('/').pop() ?? key
|
||||
const asWords = lastSegment.replace(/-/g, ' ').toLowerCase()
|
||||
|
||||
return entries.find(e => {
|
||||
if (e.title.toLowerCase() === keyLower) return true
|
||||
if (e.aliases.some(a => a.toLowerCase() === keyLower)) return true
|
||||
const stem = e.filename.replace(/\.md$/, '')
|
||||
if (stem.toLowerCase() === keyLower) return true
|
||||
if (e.path.endsWith(suffix)) return true
|
||||
if (stem.toLowerCase() === lastSegment.toLowerCase()) return true
|
||||
if (e.title.toLowerCase() === asWords) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,21 +4,15 @@
|
||||
*/
|
||||
import type { VaultEntry } from '../types'
|
||||
import { getTypeColor } from './typeColors'
|
||||
import { resolveEntry } from './wikilink'
|
||||
|
||||
/** Broken-link color: muted text to signal the target note doesn't exist */
|
||||
const BROKEN_LINK_COLOR = 'var(--text-muted)'
|
||||
|
||||
/** Find a vault entry matching a wikilink target string */
|
||||
/** Find a vault entry matching a wikilink target string.
|
||||
* Delegates to the unified resolveEntry for consistent case-insensitive matching. */
|
||||
export function findEntryByTarget(entries: VaultEntry[], target: string): VaultEntry | undefined {
|
||||
// Handle pipe syntax: [[path|display name]] → use path part for matching
|
||||
const key = target.includes('|') ? target.split('|')[0] : target
|
||||
const suffix = '/' + key + '.md'
|
||||
return entries.find(e =>
|
||||
e.title === key ||
|
||||
e.filename.replace(/\.md$/, '') === key ||
|
||||
e.aliases.includes(key) ||
|
||||
e.path.endsWith(suffix),
|
||||
)
|
||||
return resolveEntry(entries, target)
|
||||
}
|
||||
|
||||
/** Resolve the accent color for a given entry based on its type */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks, extractBacklinkContext } from './wikilinks'
|
||||
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords, extractOutgoingLinks, extractBacklinkContext, extractSnippet } from './wikilinks'
|
||||
|
||||
interface TestBlock {
|
||||
type?: string
|
||||
@@ -485,3 +485,97 @@ describe('extractBacklinkContext', () => {
|
||||
expect(result).toBe('Short [[My Note]].')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSnippet', () => {
|
||||
it('extracts first paragraph after frontmatter and title', () => {
|
||||
const content = '---\ntype: Note\n---\n\n# My Note\n\nThis is the first paragraph of content.\n\n## Section Two\n\nMore content here.'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('This is the first paragraph')
|
||||
expect(snippet).toContain('More content here')
|
||||
})
|
||||
|
||||
it('strips markdown formatting (bold, italic, code)', () => {
|
||||
const content = '# Title\n\nSome **bold** and *italic* and `code` text.'
|
||||
expect(extractSnippet(content)).toBe('Some bold and italic and code text.')
|
||||
})
|
||||
|
||||
it('strips markdown links, keeps display text', () => {
|
||||
const content = '# Title\n\nSee [this link](https://example.com) and [[wiki link]].'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('this link')
|
||||
expect(snippet).not.toContain('https://example.com')
|
||||
expect(snippet).toContain('wiki link')
|
||||
expect(snippet).not.toContain('[[')
|
||||
})
|
||||
|
||||
it('uses display text from aliased wikilinks', () => {
|
||||
const content = '# Title\n\nDiscussed in [[meetings/standup|standup]] today.'
|
||||
expect(extractSnippet(content)).toBe('Discussed in standup today.')
|
||||
})
|
||||
|
||||
it('truncates long content to ~160 chars with ellipsis', () => {
|
||||
const content = `# Title\n\n${'word '.repeat(100)}`
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet.length).toBeLessThanOrEqual(165)
|
||||
expect(snippet).toMatch(/\.\.\.$/)
|
||||
})
|
||||
|
||||
it('returns empty string for note with only title', () => {
|
||||
const content = '---\ntype: Note\n---\n\n# Just a Title\n'
|
||||
expect(extractSnippet(content)).toBe('')
|
||||
})
|
||||
|
||||
it('skips code fence delimiters', () => {
|
||||
const content = '# Title\n\n```rust\nfn main() {}\n```\n\nReal content here.'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).not.toContain('```')
|
||||
expect(snippet).toContain('Real content here')
|
||||
})
|
||||
|
||||
it('returns empty for content with only headings', () => {
|
||||
const content = '# Title\n\n## Section One\n\n### Sub Section\n'
|
||||
expect(extractSnippet(content)).toBe('')
|
||||
})
|
||||
|
||||
it('handles content without frontmatter or title', () => {
|
||||
const content = 'Just plain text content without any heading.'
|
||||
expect(extractSnippet(content)).toBe('Just plain text content without any heading.')
|
||||
})
|
||||
|
||||
it('skips horizontal rules', () => {
|
||||
const content = '# Title\n\n---\n\nContent after rule.'
|
||||
expect(extractSnippet(content)).toBe('Content after rule.')
|
||||
})
|
||||
|
||||
it('handles strikethrough', () => {
|
||||
const content = '# Title\n\n~~deleted~~ text remains.'
|
||||
expect(extractSnippet(content)).toBe('deleted text remains.')
|
||||
})
|
||||
|
||||
it('extracts snippet from project-template note with body text', () => {
|
||||
const content = [
|
||||
'---', 'type: Project', 'status: Active', '---', '',
|
||||
'# Ship MVP of Tolaria', '',
|
||||
'## Objective', '',
|
||||
'Ship the minimum viable product for Tolaria marketplace.', '',
|
||||
'## Key Results', '',
|
||||
'- 100 beta users signed up',
|
||||
].join('\n')
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('Ship the minimum viable product')
|
||||
})
|
||||
|
||||
it('includes list items in snippet', () => {
|
||||
const content = '# Title\n\n- First item\n- Second item\n- Third item'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('First item')
|
||||
expect(snippet).toContain('Second item')
|
||||
})
|
||||
|
||||
it('includes code content lines (not fences) in snippet', () => {
|
||||
const content = '# Title\n\n```\nfn main() {}\n```\n\nSome text.'
|
||||
const snippet = extractSnippet(content)
|
||||
expect(snippet).toContain('fn main()')
|
||||
expect(snippet).toContain('Some text.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,6 +154,62 @@ export function extractBacklinkContext(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check if a line is useful for snippet extraction (not blank, heading, code fence, or rule). */
|
||||
function isSnippetLine(line: string): boolean {
|
||||
const t = line.trim()
|
||||
return t !== '' && !t.startsWith('#') && !t.startsWith('```') && !t.startsWith('---')
|
||||
}
|
||||
|
||||
/** Remove the first H1 heading line, allowing leading blank lines. */
|
||||
function removeH1Line(body: string): string {
|
||||
const lines = body.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().startsWith('# ')) return lines.slice(i + 1).join('\n')
|
||||
if (lines[i].trim() !== '') return body
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/** Strip markdown formatting chars: bold, italic, code, strikethrough, and resolve links. */
|
||||
function stripMarkdownChars(s: string): string {
|
||||
let result = ''
|
||||
let i = 0
|
||||
while (i < s.length) {
|
||||
if (s[i] === '[' && s[i + 1] === '[') {
|
||||
i += 2
|
||||
let inner = ''
|
||||
while (i < s.length - 1 && !(s[i] === ']' && s[i + 1] === ']')) { inner += s[i]; i++ }
|
||||
if (i < s.length - 1) i += 2
|
||||
const pipe = inner.indexOf('|')
|
||||
result += pipe !== -1 ? inner.slice(pipe + 1) : inner
|
||||
} else if (s[i] === '[') {
|
||||
i++
|
||||
let text = ''
|
||||
while (i < s.length && s[i] !== ']') { text += s[i]; i++ }
|
||||
if (i < s.length) i++
|
||||
if (i < s.length && s[i] === '(') { i++; while (i < s.length && s[i] !== ')') i++; if (i < s.length) i++ }
|
||||
result += text
|
||||
} else if (s[i] === '*' || s[i] === '_' || s[i] === '`' || s[i] === '~') {
|
||||
i++
|
||||
} else {
|
||||
result += s[i]
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Extract a snippet: first ~160 chars of body content, stripped of markdown.
|
||||
* Mirrors the Rust extract_snippet() logic for frontend use. */
|
||||
export function extractSnippet(content: string): string {
|
||||
const [, body] = splitFrontmatter(content)
|
||||
const withoutH1 = removeH1Line(body)
|
||||
const clean = withoutH1.split('\n').filter(isSnippetLine).join(' ')
|
||||
const stripped = stripMarkdownChars(clean)
|
||||
if (stripped.length <= 160) return stripped
|
||||
return stripped.slice(0, 160) + '...'
|
||||
}
|
||||
|
||||
export function countWords(content: string): number {
|
||||
const [, body] = splitFrontmatter(content)
|
||||
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')
|
||||
|
||||
12
tests/fixtures/test-vault/event/team-meeting.md
vendored
Normal file
12
tests/fixtures/test-vault/event/team-meeting.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
Is A: Event
|
||||
Status: Active
|
||||
Belongs to:
|
||||
- "[[Alpha Project]]"
|
||||
Attendees:
|
||||
- "[[Test User]]"
|
||||
---
|
||||
|
||||
# Team Meeting
|
||||
|
||||
Weekly team meeting for the Alpha Project.
|
||||
8
tests/fixtures/test-vault/note/archived-note.md
vendored
Normal file
8
tests/fixtures/test-vault/note/archived-note.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
Is A: Note
|
||||
Archived: Yes
|
||||
---
|
||||
|
||||
# Archived Note
|
||||
|
||||
This note is archived and should not appear in the main sidebar.
|
||||
10
tests/fixtures/test-vault/note/note-b.md
vendored
Normal file
10
tests/fixtures/test-vault/note/note-b.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
Is A: Note
|
||||
Status: Active
|
||||
---
|
||||
|
||||
# Note B
|
||||
|
||||
This is Note B, referenced by Alpha Project.
|
||||
|
||||
It also links to [[Note C]].
|
||||
12
tests/fixtures/test-vault/note/note-c.md
vendored
Normal file
12
tests/fixtures/test-vault/note/note-c.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
Is A: Note
|
||||
Status: Active
|
||||
Related to:
|
||||
- "[[Alpha Project]]"
|
||||
Topics:
|
||||
- "[[design]]"
|
||||
---
|
||||
|
||||
# Note C
|
||||
|
||||
This is Note C with relationships to Alpha Project.
|
||||
9
tests/fixtures/test-vault/note/trashed-note.md
vendored
Normal file
9
tests/fixtures/test-vault/note/trashed-note.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
Is A: Note
|
||||
Trashed: true
|
||||
Trashed at: 2026-03-01
|
||||
---
|
||||
|
||||
# Trashed Note
|
||||
|
||||
This note is trashed and should not appear in search results or the main sidebar.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user