Compare commits
109 Commits
v0.2026033
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab3de7eecd | ||
|
|
188cd7af8b | ||
|
|
248ec02dee | ||
|
|
9f2bd669fe | ||
|
|
b786b2a4cb | ||
|
|
83dad79692 | ||
|
|
e7ea808f2b | ||
|
|
96df0e6796 | ||
|
|
2bec65a445 | ||
|
|
4fe6f15aa1 | ||
|
|
a093ff4631 | ||
|
|
3ed1fb4ec4 | ||
|
|
7ed0787990 | ||
|
|
56ddaba105 | ||
|
|
6e5652487d | ||
|
|
dab5f3bc41 | ||
|
|
c662b541c7 | ||
|
|
b467fd8446 | ||
|
|
4d6f713a59 | ||
|
|
284af17483 | ||
|
|
2a21dc4b08 | ||
|
|
6946aad139 | ||
|
|
4ae07446a8 | ||
|
|
88a7926a48 | ||
|
|
fd642889c8 | ||
|
|
e2d0a46608 | ||
|
|
990470e5b4 | ||
|
|
33db64822d | ||
|
|
931fed879b | ||
|
|
f71e899b90 | ||
|
|
9960fd6139 | ||
|
|
e3923fe123 | ||
|
|
375baa4e1a | ||
|
|
321a518a1f | ||
|
|
ed549a160c | ||
|
|
3de211df96 | ||
|
|
f84faac7c3 | ||
|
|
ee69e30b6b | ||
|
|
e823243a3b | ||
|
|
85e8eb7041 | ||
|
|
6640e74a74 | ||
|
|
be98fd51e5 | ||
|
|
b1aaae82df | ||
|
|
2f658425df | ||
|
|
5ce1431522 | ||
|
|
36febb75da | ||
|
|
8a923a95cf | ||
|
|
c0fed9c5c0 | ||
|
|
4d787d6f84 | ||
|
|
3bc824940a | ||
|
|
cebeca678f | ||
|
|
dd59ee072d | ||
|
|
828d5f84a9 | ||
|
|
459ee8c7e3 | ||
|
|
62e1dbb173 | ||
|
|
f15dc0e516 | ||
|
|
491e5d3962 | ||
|
|
1199840fdc | ||
|
|
39db25a39a | ||
|
|
213e51c135 | ||
|
|
9a253392e5 | ||
|
|
c4001ec3f6 | ||
|
|
e3e60a2815 | ||
|
|
e43e2a7549 | ||
|
|
517f1c04f5 | ||
|
|
635d793d32 | ||
|
|
093f1bc9dc | ||
|
|
7dc7897367 | ||
|
|
46a08c6f43 | ||
|
|
eb7a45adf9 | ||
|
|
e89dc65c22 | ||
|
|
ce4736b619 | ||
|
|
7d94bb26bb | ||
|
|
b78e42272e | ||
|
|
4d0e7469b9 | ||
|
|
c29206da3b | ||
|
|
6764fd04a1 | ||
|
|
59ed6897c1 | ||
|
|
9b59c269d8 | ||
|
|
ff1f166ca6 | ||
|
|
289ab82ed1 | ||
|
|
94da70ba30 | ||
|
|
bd130171df | ||
|
|
2045e13404 | ||
|
|
d83121bc83 | ||
|
|
acfceb3335 | ||
|
|
2dd6a94ef8 | ||
|
|
296d474732 | ||
|
|
98a98ab024 | ||
|
|
2b85640521 | ||
|
|
c3b397f900 | ||
|
|
6e0b578590 | ||
|
|
860efc1f42 | ||
|
|
d05bc271a8 | ||
|
|
7f0134a99c | ||
|
|
8fbf035d46 | ||
|
|
859795879c | ||
|
|
0ee4862508 | ||
|
|
e1def7f8bb | ||
|
|
e697b4b5e5 | ||
|
|
6b0bb5173c | ||
|
|
81f986a065 | ||
|
|
564ca50206 | ||
|
|
6d405a763d | ||
|
|
7c9bc3d640 | ||
|
|
d316539a91 | ||
|
|
0f22475c20 | ||
|
|
797c7b66b6 | ||
|
|
af7d79fe44 |
42
.claude/commands/laputa-done.md
Normal file
42
.claude/commands/laputa-done.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# /laputa-done <task_id>
|
||||
|
||||
Mark a Laputa task as done: add completion comment, move to In Review, then self-dispatch the next task.
|
||||
|
||||
Run this after Phase 1 (Playwright) and Phase 2 (native app QA) both pass **and `git push origin main` has succeeded**.
|
||||
|
||||
⚠️ A task is NOT done until the push succeeds. If the push is blocked by the pre-push hook (clippy, tests, CodeScene, build):
|
||||
- Read the error
|
||||
- Fix it (never use `--no-verify`)
|
||||
- Commit the fix and push again
|
||||
- Repeat until push exits with code 0
|
||||
|
||||
## Steps
|
||||
|
||||
**1. Add completion comment to the task**
|
||||
|
||||
Summarize what was done — this is the context Luca and Brian will read in Todoist:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://api.todoist.com/api/v1/comments" \
|
||||
-H "Authorization: Bearer $TODOIST_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"task_id": "$ARGUMENTS",
|
||||
"content": "✅ Implementation complete.\n\n**What changed:** [brief summary of the implementation]\n**ADR:** [if an ADR was created, reference it here; otherwise omit]\n**Playwright:** all tests pass\n**Native QA:** tested with pnpm tauri dev — [describe what was tested and what was observed]"
|
||||
}'
|
||||
```
|
||||
|
||||
**2. Move task to In Review**
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://api.todoist.com/api/v1/tasks/$ARGUMENTS/move" \
|
||||
-H "Authorization: Bearer $TODOIST_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"section_id": "6g3XjX33FF4Vj86M"}'
|
||||
```
|
||||
|
||||
**3. Pick the next task**
|
||||
|
||||
Run `/laputa-next-task` to get the next task and start working on it immediately.
|
||||
|
||||
If there are no tasks, `/laputa-next-task` will wait 10 minutes and retry automatically. Do NOT exit — stay alive and let it loop.
|
||||
56
.claude/commands/laputa-next-task.md
Normal file
56
.claude/commands/laputa-next-task.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# /laputa-next-task
|
||||
|
||||
Pick the next Laputa task from Todoist and move it to In Progress.
|
||||
|
||||
Priority order: **To Rework** first, then **Open** (sorted by Todoist priority p1→p4).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Fetch tasks from To Rework (`6g6QqvR9rRpvJWvv`), then Open (`6g3XjWR832hVHhCM`)
|
||||
2. Sort each section by priority (p1 highest)
|
||||
3. Take the first available task
|
||||
4. Move it to In Progress (`6g3XjWjfmJFcGgHM`) via Todoist API:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://api.todoist.com/api/v1/tasks/<task_id>/move" \
|
||||
-H "Authorization: Bearer $TODOIST_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"section_id": "6g3XjWjfmJFcGgHM"}'
|
||||
```
|
||||
|
||||
5. Add a "started" comment to the task:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://api.todoist.com/api/v1/comments" \
|
||||
-H "Authorization: Bearer $TODOIST_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"task_id": "<task_id>", "content": "🚀 Starting work. [Brief description of approach or what needs to be fixed]"}'
|
||||
```
|
||||
|
||||
6. Fetch the full task details (description, comments) from Todoist:
|
||||
|
||||
```bash
|
||||
curl -s "https://api.todoist.com/api/v1/tasks/<task_id>" \
|
||||
-H "Authorization: Bearer $TODOIST_API_KEY"
|
||||
|
||||
curl -s "https://api.todoist.com/api/v1/comments?task_id=<task_id>" \
|
||||
-H "Authorization: Bearer $TODOIST_API_KEY"
|
||||
```
|
||||
|
||||
6. For To Rework tasks: read the ❌ QA failed comment — it tells you exactly what to fix
|
||||
7. Output: task ID, title, and full description so you can start working immediately
|
||||
|
||||
If no tasks are available in either section → wait 10 minutes and try again (loop forever):
|
||||
|
||||
```bash
|
||||
while true; do
|
||||
# ... check tasks ...
|
||||
if no_tasks; then
|
||||
sleep 600 # 10 minutes
|
||||
else
|
||||
break # got a task, proceed
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
Do NOT exit when there are no tasks. Keep looping until a task appears. This keeps Claude Code alive permanently — the watchdog is a safety net only, not the primary dispatcher.
|
||||
2
.codescene-thresholds
Normal file
2
.codescene-thresholds
Normal file
@@ -0,0 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.72
|
||||
AVERAGE_THRESHOLD=9.34
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copy to .env.local and fill in real values
|
||||
# These are never committed — .env.local is gitignored
|
||||
|
||||
# Sentry DSN (https://sentry.io → Project → Settings → Client Keys)
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
# PostHog (https://posthog.com → Project → Settings → Project API Key)
|
||||
VITE_POSTHOG_KEY=
|
||||
VITE_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -67,3 +67,7 @@ CODE-HEALTH-REPORT.md
|
||||
# Tauri signing keys (never commit private keys)
|
||||
*.key
|
||||
*.key.pub
|
||||
|
||||
# Local environment variables (never commit)
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -110,12 +110,19 @@ else
|
||||
echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)"
|
||||
fi
|
||||
|
||||
# ── 5. CodeScene code health gate ────────────────────────────────────────
|
||||
# Blocks push if Hotspot < 9.5 OR Average < 9.33.
|
||||
# Remote scores reflect state after last push — this catches regressions
|
||||
# introduced in a previous push that weren't caught yet.
|
||||
# ── 5. CodeScene code health gate (ratchet) ──────────────────────────────
|
||||
# Thresholds live in .codescene-thresholds and only ever go UP (ratchet).
|
||||
# After every successful push the file is updated if scores improved.
|
||||
THRESHOLDS_FILE="$(git rev-parse --show-toplevel)/.codescene-thresholds"
|
||||
HOTSPOT_MIN=9.5
|
||||
AVERAGE_MIN=9.31
|
||||
if [ -f "$THRESHOLDS_FILE" ]; then
|
||||
HOTSPOT_MIN=$(grep HOTSPOT_THRESHOLD "$THRESHOLDS_FILE" | cut -d= -f2)
|
||||
AVERAGE_MIN=$(grep AVERAGE_THRESHOLD "$THRESHOLDS_FILE" | cut -d= -f2)
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏥 [5/5] CodeScene code health (Hotspot ≥9.5 + Average ≥9.33)..."
|
||||
echo "🏥 [5/5] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
|
||||
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
|
||||
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
|
||||
else
|
||||
@@ -128,25 +135,44 @@ else
|
||||
if [ -z "$HOTSPOT_SCORE" ] || [ -z "$AVERAGE_SCORE" ]; then
|
||||
echo " ⚠️ Could not fetch remote scores — skipping (CI will enforce)"
|
||||
else
|
||||
echo " Remote Hotspot Code Health: $HOTSPOT_SCORE (threshold: 9.5)"
|
||||
echo " Remote Average Code Health: $AVERAGE_SCORE (threshold: 9.33)"
|
||||
echo " Remote Hotspot Code Health: $HOTSPOT_SCORE (threshold: $HOTSPOT_MIN)"
|
||||
echo " Remote Average Code Health: $AVERAGE_SCORE (threshold: $AVERAGE_MIN)"
|
||||
python3 -c "
|
||||
import sys
|
||||
import sys, os
|
||||
|
||||
hotspot = float('$HOTSPOT_SCORE')
|
||||
average = float('$AVERAGE_SCORE')
|
||||
hotspot_min = float('$HOTSPOT_MIN')
|
||||
average_min = float('$AVERAGE_MIN')
|
||||
failed = False
|
||||
if hotspot < 9.5:
|
||||
print(f'FAIL: Hotspot Code Health {hotspot:.2f} < 9.5')
|
||||
|
||||
if hotspot < hotspot_min:
|
||||
print(f'FAIL: Hotspot Code Health {hotspot:.2f} < {hotspot_min}')
|
||||
failed = True
|
||||
else:
|
||||
print(f'OK: Hotspot {hotspot:.2f} >= 9.5')
|
||||
if average < 9.33:
|
||||
print(f'FAIL: Average Code Health {average:.2f} < 9.33 — regressions detected, fix before pushing')
|
||||
print(f'OK: Hotspot {hotspot:.2f} >= {hotspot_min}')
|
||||
|
||||
if average < average_min:
|
||||
print(f'FAIL: Average Code Health {average:.2f} < {average_min} — regressions detected, fix before pushing')
|
||||
failed = True
|
||||
else:
|
||||
print(f'OK: Average {average:.2f} >= 9.33')
|
||||
print(f'OK: Average {average:.2f} >= {average_min}')
|
||||
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
|
||||
# Ratchet: update thresholds file if scores improved
|
||||
# Truncate (floor) to 2 decimals so the threshold never exceeds the actual score
|
||||
import math
|
||||
thresholds_file = '$THRESHOLDS_FILE'
|
||||
new_hotspot = max(hotspot_min, math.floor(hotspot * 100) / 100)
|
||||
new_average = max(average_min, math.floor(average * 100) / 100)
|
||||
if new_hotspot > hotspot_min or new_average > average_min:
|
||||
with open(thresholds_file, 'w') as f:
|
||||
f.write(f'HOTSPOT_THRESHOLD={new_hotspot}\nAVERAGE_THRESHOLD={new_average}\n')
|
||||
print(f' 📈 Ratchet updated: Hotspot {hotspot_min} → {new_hotspot}, Average {average_min} → {new_average}')
|
||||
# Stage and amend the thresholds update into the last commit would change history — instead just auto-commit it
|
||||
os.system(f'git add {thresholds_file} && git commit -m \"chore: ratchet CodeScene thresholds to {new_hotspot}/{new_average}\" --no-verify 2>/dev/null || true')
|
||||
" || exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
154
CLAUDE.md
154
CLAUDE.md
@@ -1,38 +1,32 @@
|
||||
# CLAUDE.md — Laputa App
|
||||
|
||||
## ⛔ BEFORE EVERY COMMIT
|
||||
> Quick links: [Project Spec](docs/PROJECT-SPEC.md) · [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
---
|
||||
|
||||
**CodeScene Code Health** — the pre-commit and pre-push hooks enforce:
|
||||
- Hotspot Code Health ≥ 9.5 (most-edited files)
|
||||
- Average Code Health ≥ 9.31 (project-wide, ALL files)
|
||||
## 1. Task Workflow
|
||||
|
||||
**Both gates block commit/push.** If either fails: extract hooks, split large components, reduce function complexity. Never add `// eslint-disable`, `#[allow(...)]`, or `as any` to pass the gate. Check both scores via MCP CodeScene after every significant change:
|
||||
- `hotspot_code_health.now` ≥ 9.5
|
||||
- `code_health.now` ≥ 9.31 (average — do NOT ignore this one)
|
||||
### 1a. Pick up a task
|
||||
|
||||
If Average Code Health is below 9.0, you must fix regressions before pushing — even in files you didn't directly modify, if your changes indirectly affected complexity.
|
||||
Run `/laputa-next-task` — fetches next task (To Rework first, then Open), moves to In Progress, returns full description.
|
||||
|
||||
**Boy Scout Rule (Robert C. Martin):** Leave every file you touch better than you found it. When working on any task:
|
||||
1. Before modifying a file, check its CodeScene health: `mcp__codescene__code_health_review`
|
||||
2. If the file has issues (complexity, duplication, large functions), fix them as part of your work
|
||||
3. After your changes, verify the file's score is higher than before: `mcp__codescene__code_health_score`
|
||||
4. The goal: every commit either maintains or raises the overall average. No commit should lower it.
|
||||
- Read task description and all comments fully
|
||||
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
|
||||
- Check `docs/adr/` for relevant architecture decisions before structural choices
|
||||
- Add a comment: `🚀 Starting work on this task. [Brief description of approach]`
|
||||
|
||||
This is not optional — it's how we incrementally raise the codebase quality with every task.
|
||||
### 1b. Implement
|
||||
|
||||
## ⛔ BEFORE FIRING laputa-task-done — Two-phase QA
|
||||
- Work on `main` branch — **no branches, no PRs, ever**
|
||||
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||
- **⛔ NEVER use --no-verify**
|
||||
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode
|
||||
|
||||
### Phase 1: Playwright (you do this)
|
||||
### 1c. When done
|
||||
|
||||
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:
|
||||
**Phase 1 — Playwright (only for core user flows):**
|
||||
|
||||
Write smoke test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Do NOT write Playwright tests for cosmetic changes — use Vitest instead. Suite must stay under **10 minutes**.
|
||||
|
||||
```bash
|
||||
pnpm dev --port 5201 &
|
||||
@@ -40,95 +34,91 @@ sleep 3
|
||||
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```
|
||||
|
||||
**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`.
|
||||
|
||||
### Phase 2: Native QA (Brian does this after push)
|
||||
|
||||
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 — **two steps, both required**:
|
||||
**Phase 2 — Native app QA:**
|
||||
|
||||
```bash
|
||||
# 1. Move task to In Review on Todoist
|
||||
curl -s -X POST "https://api.todoist.com/api/v1/tasks/<task_id>/move" \
|
||||
-H "Authorization: Bearer $TODOIST_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"section_id": "6g3XjX33FF4Vj86M"}'
|
||||
|
||||
# 2. Notify Brian
|
||||
openclaw system event --text "laputa-task-done:<task_id>" --mode now
|
||||
pnpm tauri dev &
|
||||
sleep 10
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
```
|
||||
|
||||
## Project
|
||||
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
||||
|
||||
Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with YAML frontmatter.
|
||||
After both phases pass, run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
|
||||
|
||||
- **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
|
||||
## 2. Development Process
|
||||
|
||||
- **Push directly to main** — no PRs ever. The pre-push hook runs all checks.
|
||||
- **⛔ NEVER open a PR** — branches diverge and cause rebase churn.
|
||||
### Commits & pushes
|
||||
|
||||
- Push directly to `main` — no PRs, no branches
|
||||
- Pre-push hook runs full check suite (build + tests + Playwright + CodeScene)
|
||||
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. Never use `--no-verify`.
|
||||
- **⛔ NEVER use --no-verify**
|
||||
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||
|
||||
## TDD (mandatory)
|
||||
### TDD (mandatory)
|
||||
|
||||
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.
|
||||
Red → Green → Refactor → Commit. One cycle per commit. For bugs: write failing regression test first, then fix. Exception: pure CSS/layout changes.
|
||||
|
||||
**Test quality (Kent Beck's Desiderata):** every test must be Isolated (no shared state), Deterministic (no flakiness), Fast, Behavioral (tests behavior not implementation), Structure-insensitive (refactoring doesn't break it), Specific (failure points to exact cause), Predictive (all pass = production-ready). Fix flaky/non-deterministic tests before adding new ones. E2E tests over unit tests for user flows.
|
||||
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests before adding new ones. Prefer E2E over unit tests for user flows.
|
||||
|
||||
## ⛔ Docs — Keep docs/ in sync
|
||||
### Code health (mandatory)
|
||||
|
||||
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.
|
||||
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up, auto-updated after each successful push. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
|
||||
|
||||
## Architecture Decision Records (ADRs)
|
||||
**Before every commit:**
|
||||
- `mcp__codescene__code_health_review` — check file before touching
|
||||
- `mcp__codescene__code_health_score` — verify score is higher after changes
|
||||
|
||||
ADRs live in `docs/adr/`. Before making an architectural choice, check existing ADRs there first.
|
||||
**Boy Scout Rule:** every file you touch must leave with a higher score. If Average drops below 9.0, fix regressions before pushing.
|
||||
|
||||
**When to create one**: storage strategy, new dependency, platform support, core abstraction change, cross-cutting concern. Use `/create-adr` for the full template and instructions.
|
||||
### Check suite (runs on every push)
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
**Timing**: create the ADR **in the same commit as the code** that implements the decision — never before, never after. An ADR committed without the corresponding code is invalid.
|
||||
### Architecture Decision Records (ADRs)
|
||||
|
||||
**When your work supersedes an existing ADR**: do not edit the existing file — use `/create-adr` which covers the superseding flow.
|
||||
ADRs live in `docs/adr/`. Check before structural choices. Create the ADR **in the same commit as the code**. Never edit existing ADRs — create a new one that supersedes. Use `/create-adr` for template.
|
||||
|
||||
**Do not create ADRs for**: bug fixes, UI styling, refactors, or test additions.
|
||||
**When to create one:** new dependency, storage strategy, platform target, core abstraction change, cross-cutting pattern. **Not for:** bug fixes, UI styling, refactors, test additions.
|
||||
|
||||
## Design File (UI tasks)
|
||||
### Keep docs/ in sync
|
||||
|
||||
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`.
|
||||
After 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.
|
||||
|
||||
## ⛔ Never modify the user vault for testing
|
||||
---
|
||||
|
||||
`~/Laputa/` is Luca's real vault. **Never create, edit, or delete notes there for testing purposes.**
|
||||
## 3. Product Rules
|
||||
|
||||
Use the demo vault for all testing:
|
||||
- Playwright / Vitest: use the fixtures in `tests/` or `demo-vault-v2/`
|
||||
- `pnpm tauri dev` manual testing: open `demo-vault-v2/` as the vault, not `~/Laputa/`
|
||||
- If a test genuinely requires the real vault (e.g. verifying git history), read only — never write
|
||||
### User vault (`~/Laputa/`)
|
||||
|
||||
Any commit that touches `~/Laputa/` content is a bug. If you accidentally created test notes there, delete them before committing.
|
||||
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
|
||||
|
||||
## Vault Retrocompatibility
|
||||
### UI design
|
||||
|
||||
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.
|
||||
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 completion: merge frames into `ui-design.pen`, delete `design/<slug>.pen`
|
||||
|
||||
## Keyboard-First + Menu Bar (mandatory)
|
||||
---
|
||||
|
||||
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.
|
||||
## 4. Reference
|
||||
|
||||
## macOS / Tauri Gotchas
|
||||
### macOS / Tauri gotchas
|
||||
|
||||
- `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.
|
||||
- `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 testing
|
||||
|
||||
## QA Scripts
|
||||
### QA scripts
|
||||
|
||||
```bash
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
@@ -136,6 +126,6 @@ bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
|
||||
```
|
||||
|
||||
## Documentation Diagrams
|
||||
### Diagrams
|
||||
|
||||
Prefer Mermaid for all diagrams (`flowchart`, `sequenceDiagram`, `classDiagram`, `stateDiagram-v2`). ASCII only for spatial wireframe layouts. GitHub renders Mermaid natively.
|
||||
Prefer Mermaid (`flowchart`, `sequenceDiagram`, `classDiagram`, `stateDiagram-v2`). ASCII only for spatial wireframe layouts.
|
||||
|
||||
@@ -27,3 +27,16 @@ As Laputa added more features that store configuration in note frontmatter (pinn
|
||||
- Power users can still access and edit system properties via the raw editor.
|
||||
- Type documents use `_icon`, `_color`, `_order`, `_sidebar_label`, `_pinned_properties`.
|
||||
- Re-evaluation trigger: if the number of system properties grows large enough to warrant a structured sub-object.
|
||||
|
||||
## Normalized system properties
|
||||
|
||||
| Canonical key | Old keys (read with fallback) | Written by |
|
||||
|---|---|---|
|
||||
| `_archived` | `Archived`, `archived` | Archive action |
|
||||
| `_trashed` | `Trashed`, `trashed` | Trash action |
|
||||
| `_trashed_at` | `Trashed at`, `trashed_at` | Trash action |
|
||||
| `_favorite` | — | Favorite toggle |
|
||||
| `_favorite_index` | — | Favorite reorder |
|
||||
|
||||
**Write rule**: always use the canonical `_`-prefixed key.
|
||||
**Read rule**: accept both canonical and legacy keys (case-insensitive). Do NOT rewrite on read — migration is a separate concern.
|
||||
|
||||
30
docs/adr/0031-full-app-for-note-windows.md
Normal file
30
docs/adr/0031-full-app-for-note-windows.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0031"
|
||||
title: "Full App instance for secondary note windows"
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa supports opening a note in a secondary window ("Open in New Window"). The original implementation used a dedicated `NoteWindow` component — a thin shell that rendered only the editor, duplicating some App-level logic (vault loading, settings, keyboard shortcuts) in a simplified but diverging form. As the main App gained features (properties editing, zoom, command palette, keyboard shortcuts), the `NoteWindow` shell fell behind, requiring ongoing maintenance to keep parity.
|
||||
|
||||
## Decision
|
||||
|
||||
**Remove `NoteWindow` and render the full `App` component in secondary note windows.** The window type is detected at startup via URL query parameters (`?window=note&path=...&vault=...`). When in note-window mode, the App initialises with panels hidden (sidebar collapsed, inspector collapsed) and auto-opens the target note once vault entries load. The window title is kept in sync with the active note title via the Tauri window API.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Keep `NoteWindow` shell** (status quo): lower initial bundle weight per window, but divergence grows with every main-App feature. Rejected — maintenance cost dominates.
|
||||
- **Full `App` instance with URL-param mode** (chosen): complete feature parity for free; single code path for all window types. Trade-off: slightly heavier startup for secondary windows (full vault load), acceptable given local filesystem speed.
|
||||
- **IPC-driven secondary window (no vault reload)**: secondary window subscribes to primary window's vault state via Tauri events. Maximum efficiency, avoids double vault reads. Deferred — requires significant IPC plumbing; can be layered on top later without changing the rendering model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Removes ~163 lines (`NoteWindow.tsx` deleted entirely)
|
||||
- Secondary note windows get full feature parity: all keyboard shortcuts, properties panel, zoom, command palette, diff mode, raw editor
|
||||
- `useLayoutPanels` gains an `initialInspectorCollapsed` option to support the hidden-panel initial state
|
||||
- A new `src/utils/windowMode.ts` utility encapsulates URL-param detection — single source of truth for window-type logic
|
||||
- Vault is loaded independently in each note window (no shared state with the main window); writes go to the same filesystem so eventual consistency is maintained via file-watching
|
||||
- Triggers re-evaluation if: multiple simultaneous note windows cause measurable vault-read contention, or if IPC-driven shared-state windows become a product requirement
|
||||
30
docs/adr/0032-status-bar-for-git-actions.md
Normal file
30
docs/adr/0032-status-bar-for-git-actions.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0032"
|
||||
title: 0032 Status Bar For Git Actions
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
[[Subfolder scanning and folder tree navigation]]
|
||||
|
||||
## Context
|
||||
|
||||
The Laputa sidebar originally surfaced git-related affordances — a "Changes" nav item (visible when modified files > 0), a "Pulse" nav item, and a "Commit & Push" button — alongside the note-type navigation filters and sections. This mixed two concerns in the sidebar: **navigation** (where to go) and **git status / actions** (what changed, what to do). As the sidebar grew, the git items created visual noise and made the nav hierarchy harder to scan.
|
||||
|
||||
## Decision
|
||||
|
||||
**Move Changes, Pulse, and Commit & Push out of the sidebar and into the bottom status bar.** The status bar shows a GitDiff icon with an orange count badge for modified files; a Pulse icon sits next to it. Commit & Push is accessible via an icon button beside the Changes indicator. The sidebar now contains only navigation items (filters and type sections).
|
||||
|
||||
## Options considered
|
||||
|
||||
* **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan.
|
||||
* **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom.
|
||||
* **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected.
|
||||
|
||||
## Consequences
|
||||
|
||||
* Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only
|
||||
* `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props
|
||||
* Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended
|
||||
* Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state
|
||||
* Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar
|
||||
34
docs/adr/0033-subfolder-scanning-and-folder-tree.md
Normal file
34
docs/adr/0033-subfolder-scanning-and-folder-tree.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0033"
|
||||
title: "Subfolder scanning and folder tree navigation"
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
## Context
|
||||
|
||||
[[0032 Status Bar For Git Actions]]
|
||||
|
||||
Supersedes the scanning constraint in [ADR-0006](0006-flat-vault-structure.md) which limited vault indexing to root-level `.md` files plus protected folders (`attachments/`, `assets/`).
|
||||
|
||||
Users with folder-based workflows (PARA, Zettelkasten with folders, project directories) could not see or filter notes by directory. The vault scanner silently ignored all subdirectory `.md` files, making Laputa unsuitable for vaults with any folder structure.
|
||||
|
||||
## Decision
|
||||
|
||||
**Extend the Rust vault scanner to index **`.md`** files in all visible subdirectories, and expose the vault's folder tree via a new **`list_vault_folders`** Tauri command so the sidebar can render a collapsible FOLDERS section.**
|
||||
|
||||
Hidden directories (names starting with `.`, plus `.git` and `.laputa`) are excluded from both scanning and the folder tree.
|
||||
|
||||
## Options considered
|
||||
|
||||
* **Option A** (chosen): Scan all subdirectories with `walkdir`, expose separate `list_vault_folders` command — simple, no schema changes to VaultEntry, folder tree is lightweight and independent of the entry cache.
|
||||
* **Option B**: Add a `folder` field to VaultEntry and derive the tree on the frontend — couples folder metadata to the entry cache, complicates cache invalidation when folders are created/deleted without file changes.
|
||||
* **Option C**: Keep flat scanning, add a "virtual folders" feature that groups by path prefix from frontmatter — doesn't solve the core problem of missing notes in subdirectories.
|
||||
|
||||
## Consequences
|
||||
|
||||
* All `.md` files in the vault are now indexed regardless of depth — vaults with many non-note `.md` files (e.g. node_modules) will see spurious entries. Mitigation: hidden directories are already excluded; users can add a `.laputaignore` in the future if needed.
|
||||
* The git-based cache in `cache.rs` already uses `walkdir` for change detection, so this change aligns scanning with caching.
|
||||
* `SidebarSelection` gains a new `{ kind: 'folder'; path: string }` variant — all exhaustive switches on selection kind must handle it.
|
||||
* ADR-0006's "flat vault" principle is relaxed: notes can now live in subdirectories. Type definitions still live in `type/` at the root.
|
||||
* Re-evaluate if users request recursive folder filtering (currently only direct children are shown when a folder is selected).
|
||||
29
docs/adr/0034-git-repo-required-for-vault.md
Normal file
29
docs/adr/0034-git-repo-required-for-vault.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0034"
|
||||
title: "Git repo required — blocking modal enforces vault prerequisite"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0014 (git-based vault cache) and ADR-0021 (push-to-main workflow) both assume the vault is a git repository, but neither codified it as a hard enforcement. In practice, opening a non-git folder silently degraded: the cache couldn't compute a commit hash, Pulse/Changes were empty, and commit/push commands failed. The failure mode was invisible to users.
|
||||
|
||||
## Decision
|
||||
|
||||
**When the app opens a vault that has no `.git` directory, a blocking modal prevents all app use until the user either initialises a git repository (git init + initial commit, offered as a one-click action) or selects a different vault. The check is performed by a new `is_git_repo` Tauri command. In browser/dev mode, the check fails open (modal is skipped).**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Hard block via modal on vault open — unambiguous, prevents silent failures, surfaces the fix immediately. Downside: breaks existing workflows for users with non-git vaults; requires a clear escape hatch (choose different vault).
|
||||
- **Option B**: Soft warning banner, allow using the app without git — avoids blocking users, but silent failures persist for Pulse/Changes/commit features.
|
||||
- **Option C**: Auto-init git on vault open without asking — less friction, but surprising; user may not want their vault in git.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Git is now a first-class prerequisite for Laputa vaults, not just implied by the cache strategy.
|
||||
- The `is_git_repo` command is intentionally lightweight (checks for `.git` existence only; does not validate remote or commit history).
|
||||
- The modal offers `git init` + an initial commit as a one-click path, lowering the barrier for new users.
|
||||
- Browser mode bypasses the check so dev/Storybook workflows are unaffected.
|
||||
- Re-evaluate if Laputa needs to support non-git vaults (e.g., iCloud-only, shared network drive); at that point ADR-0014 would also need revisiting.
|
||||
31
docs/adr/0035-path-suffix-wikilink-resolution.md
Normal file
31
docs/adr/0035-path-suffix-wikilink-resolution.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0035"
|
||||
title: "Path-suffix wikilink resolution for subfolder vaults"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0006 stated that wikilink resolution was "simplified to multi-pass title/filename matching — no path-based matching needed" because the vault was flat. ADR-0033 relaxed the flat-vault constraint by adding subfolder scanning. As a result, wikilinks like `[[docs/adr/0031-foo]]` or `[[adr/0031-foo]]` could not resolve to entries in subdirectories: the resolver only matched on `title` and `filename` stem, never on the vault-relative path.
|
||||
|
||||
The backlink detection in the Inspector also used a hardcoded `/Laputa/` path regex, which was wrong for any vault that isn't named "Laputa".
|
||||
|
||||
## Decision
|
||||
|
||||
**Add path-suffix matching as Pass 1 of wikilink resolution: a link target resolves to a `VaultEntry` if the entry's vault-relative path ends with the link string (with or without `.md`). Filename-stem matching (the previous Pass 1) becomes Pass 2. Inspector backlinks replace the hardcoded `/Laputa/` regex with a generic `targetMatchesEntry` path-suffix helper. Autocomplete pre-filter also matches against the full vault-relative path so subfolder names surface results.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Path-suffix as Pass 1, then filename match as Pass 2 — consistent with how Obsidian resolves links in multi-folder vaults, zero config. Downside: if two notes share the same filename in different folders, only the first (path-suffix) match wins.
|
||||
- **Option B**: Strict full-path matching only (disable title-stem resolution) — unambiguous, but breaks the majority of existing short-form `[[note-title]]` links.
|
||||
- **Option C**: Keep title-only matching, require full paths for subfolder notes — backwards-compatible, but forces users to always type full paths for subfolders, defeating the purpose of wikilinks.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Supersedes the "no path-based matching needed" clause from ADR-0006 (that assumption was contingent on the flat vault invariant, which ADR-0033 relaxed).
|
||||
- `relativePathStem` utility added in `wikilink.ts` to extract the vault-relative path stem from a full `VaultEntry`.
|
||||
- The Inspector's `targetMatchesEntry` helper is now the canonical way to test if a link resolves to an entry — use it everywhere instead of ad-hoc regex.
|
||||
- Wikilink autocomplete suggestions now surface notes in subfolders when users type a folder prefix (e.g. `[[adr/`).
|
||||
- Re-evaluate if path-suffix ambiguity (two files with the same name in different folders) becomes a user complaint.
|
||||
31
docs/adr/0036-external-rename-detection-via-git-diff.md
Normal file
31
docs/adr/0036-external-rename-detection-via-git-diff.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0036"
|
||||
title: "External rename detection via git diff on focus regain"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa handles in-app renames (rename.rs) and propagates wikilink updates across the vault. But notes can also be renamed externally — from Finder, another editor, or a git operation (e.g., `git mv`). In those cases, the app had no way to detect that a rename had occurred, leaving wikilinks broken and the vault inconsistent.
|
||||
|
||||
The app already uses git for the cache (ADR-0014) and requires git as a vault prerequisite (ADR-0034), making git diff a natural and already-available detection mechanism.
|
||||
|
||||
## Decision
|
||||
|
||||
**When the app window regains focus, run `git diff --diff-filter=R --name-status HEAD` to detect file renames that occurred since the last committed HEAD. If any renamed `.md` files are found, show a non-blocking banner ("X file(s) renamed — update wikilinks?"). Accepting triggers the existing vault-wide wikilink replacement logic (reused from rename.rs). Ignoring dismisses the banner without changes. New Tauri commands: `detect_renames` and `update_wikilinks_for_renames`.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Git diff on focus regain, non-blocking banner — uses existing infrastructure, non-disruptive, user retains control. Downside: only detects renames that are staged/committed; uncommitted renames via `git mv` are captured, but renames done purely in Finder (no git involvement) are not.
|
||||
- **Option B**: `FSEvents` / file-system watcher for rename events — catches all renames regardless of git. Downside: significantly more complex, requires Rust async machinery, false positives from editor temp files, and this feature is already planned as a separate enhancement.
|
||||
- **Option C**: Scan for broken wikilinks on focus — correct but O(n) and noisy; doesn't tell us the new filename.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Git's rename detection (`--diff-filter=R`) requires the rename to be git-tracked (either staged or committed); renames that happen outside git knowledge are not detected by this mechanism.
|
||||
- The on-focus check runs `git diff HEAD` which is fast but adds a small shell invocation overhead each time the window activates. This is acceptable for typical vault sizes.
|
||||
- `rename.rs` is now shared between in-app renames and external rename recovery — the replacement logic is the canonical entry point for wikilink bulk updates.
|
||||
- The banner is non-blocking and "Ignore" is always available — the user never loses work.
|
||||
- Re-evaluate if FS-level rename detection (outside git) becomes a priority; at that point this mechanism would be a fallback, not the primary strategy.
|
||||
31
docs/adr/0037-codemirror-language-markdown-highlighting.md
Normal file
31
docs/adr/0037-codemirror-language-markdown-highlighting.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0037"
|
||||
title: "Language-based markdown syntax highlighting in raw editor"
|
||||
status: active
|
||||
date: 2026-04-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The raw editor (CodeMirror 6, introduced in ADR-0022) initially had a custom `frontmatterHighlight` extension that used regex-based decoration for YAML frontmatter and headings. Markdown body content had no syntax highlighting at all, making the raw editor feel like a plain textarea despite being a full CodeMirror instance.
|
||||
|
||||
Extending the custom regex-based approach to cover all markdown syntax (bold, italic, links, lists, blockquotes, code) would have been brittle and hard to maintain.
|
||||
|
||||
## Decision
|
||||
|
||||
**Replace the custom heading decoration in `frontmatterHighlight.ts` with `@codemirror/lang-markdown` (the official CodeMirror language package). A custom `HighlightStyle` maps CodeMirror highlight tags to visual styles for headings, bold, italic, strikethrough, links, lists, blockquotes, and inline code. The frontmatter YAML plugin is retained for YAML-specific colouring but its heading decoration is removed in favour of the language parser.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): `@codemirror/lang-markdown` with custom HighlightStyle — uses the official, maintained language parser; future highlight rules are one CSS declaration. Downside: adds a new npm dependency; the custom frontmatter plugin must be kept separately.
|
||||
- **Option B**: Extend the custom regex plugin to cover all markdown — no new dependency. Downside: regex-based tokenisation is fragile (e.g., nested formatting), already proving hard to maintain after the heading/frontmatter overlap bug.
|
||||
- **Option C**: Switch to a markdown-aware editor (e.g., Milkdown, Monaco) — full-featured. Downside: major migration, breaks the dual-editor architecture in ADR-0022, significant scope.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `@codemirror/lang-markdown` added to `package.json` — this is the only new runtime dependency introduced by this change.
|
||||
- `frontmatterHighlight.ts` is simplified (heading decoration removed); `markdownHighlight.ts` is the new extension responsible for body highlighting.
|
||||
- The two extensions are composed in `useCodeMirror.ts` — YAML frontmatter block is still styled by the custom plugin; everything else by the language parser.
|
||||
- Future syntax highlighting changes (e.g., task lists, tables) can be added by extending the `HighlightStyle` without modifying the parser.
|
||||
- Re-evaluate if `@codemirror/lang-markdown` conflicts with the custom frontmatter YAML handling as the editor evolves (e.g., if frontmatter block needs to be parsed as a code block rather than decorated text).
|
||||
32
docs/adr/0038-frontmatter-backed-favorites.md
Normal file
32
docs/adr/0038-frontmatter-backed-favorites.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0038"
|
||||
title: "Frontmatter-backed favorites with _favorite and _favorite_index"
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Users want to pin frequently-accessed notes to a dedicated FAVORITES section in the sidebar for quick navigation. The app needs a persistence mechanism for which notes are favorited and their display order.
|
||||
|
||||
## Decision
|
||||
|
||||
**Favorites are stored as two system properties in each note's YAML frontmatter: `_favorite: true` and `_favorite_index: <integer>`.**
|
||||
|
||||
- `_favorite`: boolean. Present and `true` = favorited. Absent = not favorited. Toggling off deletes the key entirely (no `_favorite: false`).
|
||||
- `_favorite_index`: integer. Controls display order in the FAVORITES sidebar section (lower = higher). Assigned automatically on favorite, updated on drag-to-reorder.
|
||||
- Both use the `_` prefix convention (ADR 0008) — they are system-owned and hidden from the Properties panel.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Frontmatter per-note (chosen)**: Each note carries its own favorite state. Portable across devices (synced via git). No separate metadata file. Cons: two extra frontmatter writes on reorder.
|
||||
- **Separate `.laputa/favorites.json` file**: Central list of favorite paths. Simpler reorder (one file write). Cons: not portable if `.laputa/` is gitignored; path references break on rename.
|
||||
- **SQLite/app-level metadata**: Fast queries. Cons: not synced via git; diverges from frontmatter-first data model established in ADR 0008.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Favorites survive vault sync via git — any client that reads frontmatter sees them.
|
||||
- Reorder writes `_favorite_index` to N files (one per affected note). Acceptable for typical favorites lists (< 20 items).
|
||||
- If `_favorite: true` exists but `_favorite_index` is absent, the note is appended to the end of the list.
|
||||
- Re-evaluate if favorites list exceeds ~50 items and reorder writes become a performance concern.
|
||||
40
docs/adr/0039-git-history-for-note-dates.md
Normal file
40
docs/adr/0039-git-history-for-note-dates.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0039"
|
||||
title: "Use git history for note creation and modification dates"
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Filesystem metadata (`ctime`/`mtime`) is unreliable for a git-backed vault. After `git clone`, `git pull`, or iCloud sync, files appear "newly created" even when they have years of history. This causes incorrect sort ordering in the note list and wrong dates in the inspector panel.
|
||||
|
||||
## Decision
|
||||
|
||||
**Use `git log` to determine the true creation and modification dates for notes.** A single batch `git log --format="COMMIT %aI" --name-only` command walks the full commit history and extracts:
|
||||
|
||||
- **modified_at** = author date of the most recent commit that touched the file
|
||||
- **created_at** = author date of the oldest commit that touched the file
|
||||
|
||||
The batch approach runs once per vault scan (not per-file), parsing the log output in a single linear pass. Results are stored in a `HashMap<String, GitDates>` keyed by vault-relative path and threaded through the existing `parse_md_file` / `scan_vault` / `scan_vault_cached` pipeline.
|
||||
|
||||
### Fallback to filesystem dates
|
||||
|
||||
- **Non-git vaults** (no `.git` directory): all notes use filesystem `mtime`/`ctime`.
|
||||
- **Uncommitted new files**: not in git log output, so filesystem dates are used automatically.
|
||||
- **Single-file reloads** (`reload_entry`): use filesystem dates since the file was just saved and the most accurate timestamp is the filesystem one.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Per-file `git log`**: Correct but O(n) subprocesses. Too slow for vaults with 500+ notes.
|
||||
- **Frontmatter dates** (e.g., `created: 2025-01-15`): Requires user discipline. Not automatic. Breaks when users forget to set them.
|
||||
- **Filesystem metadata** (current): Unreliable across clones, pulls, and cloud sync.
|
||||
- **Single batch `git log`** (chosen): One subprocess, O(n) parsing, correct dates for all committed files.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Note sort-by-created and sort-by-modified now reflect true git history, stable across clones and machines.
|
||||
- First vault scan runs one `git log` over the full history. For a vault with 1000 files and 500 commits, output is ~100KB and parses in <100ms.
|
||||
- Renamed files get `created_at` set to the rename commit date (not the original creation). Acceptable trade-off vs. the complexity of rename tracking.
|
||||
- `CACHE_VERSION` bumped from 9 to 10 to force a full rescan with git dates on upgrade.
|
||||
59
docs/adr/0040-custom-views-yml-filter-engine.md
Normal file
59
docs/adr/0040-custom-views-yml-filter-engine.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0040"
|
||||
title: "Custom views as .yml files with client-side filter engine"
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Users want to save reusable filtered note lists (e.g., "Active Projects", "This Week's Events") as named sidebar items. These views need to persist across sessions, sync via git, and support arbitrary frontmatter conditions.
|
||||
|
||||
## Decision
|
||||
|
||||
**Custom views are stored as `.yml` files in `.laputa/views/` within the vault root.** Each file defines a named view with filter conditions, optional icon/color, and sort preferences.
|
||||
|
||||
### File format
|
||||
|
||||
```yaml
|
||||
name: Active Projects
|
||||
icon: rocket
|
||||
color: blue
|
||||
sort: "modified:desc"
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- field: status
|
||||
op: not_equals
|
||||
value: done
|
||||
```
|
||||
|
||||
### Filter engine
|
||||
|
||||
Filters use a tree of AND/OR groups (`all`/`any`) containing conditions. Each condition specifies a `field`, `op` (operator), and optional `value`. Supported operators: `equals`, `not_equals`, `contains`, `not_contains`, `any_of`, `none_of`, `is_empty`, `is_not_empty`, `before`, `after`.
|
||||
|
||||
Field resolution: built-in fields (`type`, `status`, `title`, `archived`, `trashed`, `favorite`) map to VaultEntry struct fields. Unknown fields fall back to `entry.properties`, then `entry.relationships`.
|
||||
|
||||
Wikilink values like `[[target|Alias]]` are matched by stem (stripping brackets and pipe+alias).
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Rust backend** (`vault/views.rs`): YAML parsing via `serde_yaml`, filter evaluation, file CRUD. Three Tauri commands: `list_views`, `save_view_cmd`, `delete_view_cmd`.
|
||||
- **Frontend**: Client-side filter evaluation against the already-loaded `VaultEntry[]` array. The Rust `evaluate_view` exists for MCP/CLI access but is not the primary UI path.
|
||||
- **Sidebar**: VIEWS section between Favorites and Types, hidden when no views exist.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **SQLite views table**: Fast queries, but not portable via git and diverges from the file-first data model.
|
||||
- **Frontmatter on a special `.md` file**: Overloads the note format for a non-note concept.
|
||||
- **Standalone `.yml` files (chosen)**: Portable (synced via git), editable by hand or UI, naturally separated from note content.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New dependency: `serde_yaml` crate for YAML parsing.
|
||||
- `.laputa/views/` directory auto-created on first view save. Already excluded from vault scanning via `HIDDEN_DIRS`.
|
||||
- Views sync across devices via git. Conflicts resolved by standard git merge (YAML is line-based, merges well).
|
||||
- Sort persistence: changing sort while a view is selected writes `sort` back to the `.yml` file.
|
||||
39
docs/adr/0041-filekind-all-files-in-vault-scanner.md
Normal file
39
docs/adr/0041-filekind-all-files-in-vault-scanner.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0041"
|
||||
title: "fileKind field — scan all vault files, not just markdown"
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa vaults often contain non-markdown files alongside notes: images, PDFs, YAML configs, JSON exports, scripts, etc. Previously the vault scanner only indexed `.md` files — all other files were invisible to the app. This made the Folder view incomplete: navigating a folder containing a `config.yml` or `photo.png` showed nothing, even though the file was physically there.
|
||||
|
||||
The need arose when adding a Folder tree view that is meant to mirror the actual filesystem structure. Users expect to see all files in a folder, as any file manager would show.
|
||||
|
||||
## Decision
|
||||
|
||||
**The vault scanner now indexes all files (not just `.md`). Every `VaultEntry` carries a `fileKind` field (`"markdown"`, `"text"`, or `"binary"`) that controls how the frontend renders and opens it.**
|
||||
|
||||
- **`"markdown"`**: full Laputa behavior — frontmatter parsing, BlockNote editor, title sync, type system.
|
||||
- **`"text"`**: filename as title, no frontmatter, opens in raw CodeMirror editor. Covers `.yml`, `.json`, `.ts`, `.py`, `.sh`, etc.
|
||||
- **`"binary"`**: filename as title, grayed out, non-clickable. Covers images, PDFs, binaries.
|
||||
- **Hidden files** (starting with `.`) are skipped regardless of extension.
|
||||
- **Non-folder views** (All Notes, type sections, Custom Views) still show only `"markdown"` entries.
|
||||
- **Folder view** shows all file kinds.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Single `VaultEntry` model with a `fileKind` discriminator. All files go through the same pipeline; rendering is gated by `fileKind`. Simple, incremental — existing code paths untouched for markdown files.
|
||||
- **Option B**: Separate data model for non-markdown files (e.g. `AssetEntry`). Cleaner type hierarchy, but requires duplicating list/filter/sort logic for two types across the codebase.
|
||||
- **Option C**: Only scan `.md` + explicitly listed extensions (e.g. `.yml`, `.json`). Simpler initial implementation, but requires ongoing maintenance of an allowlist and still misses user files. Abandoned in favor of a deny-list approach (only `.`-prefixed hidden files are excluded).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Non-markdown files are visible in Folder view — the app now behaves like a file manager in that context.
|
||||
- All views except Folder view continue to show only markdown files (the `isMarkdown` guard in `filterEntries`).
|
||||
- `countByFilter` / `countAllByFilter` exclude non-markdown entries to keep sidebar counters accurate.
|
||||
- The vault cache version was bumped to `11` to force a full rescan after this change.
|
||||
- Binary files have no click action — clicking does nothing (no editor opened).
|
||||
- Re-evaluation trigger: if users need to preview or edit binary files (e.g. images), a dedicated preview pane would need a separate ADR.
|
||||
@@ -86,3 +86,14 @@ proposed → active → superseded
|
||||
| [0028](0028-cli-agent-only-no-api-key.md) | CLI agent only — no direct Anthropic API key | active |
|
||||
| [0029](0029-domain-command-builder-pattern.md) | Domain command builder pattern for useCommandRegistry | active |
|
||||
| [0030](0030-rust-commands-module-split.md) | Rust commands/ module split by domain | active |
|
||||
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
|
||||
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
|
||||
| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active |
|
||||
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active |
|
||||
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
|
||||
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
|
||||
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |
|
||||
| [0038](0038-frontmatter-backed-favorites.md) | Frontmatter-backed favorites (_favorite, _favorite_index) | active |
|
||||
| [0039](0039-git-history-for-note-dates.md) | Git history as source of truth for note creation/modification dates | active |
|
||||
| [0040](0040-custom-views-yml-filter-engine.md) | Custom Views — .laputa/views/*.yml with YAML filter engine | active |
|
||||
| [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active |
|
||||
|
||||
@@ -5,26 +5,27 @@ title: "Canary release channel and local feature flags"
|
||||
status: active
|
||||
date: 2026-03-25
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Shipping new features directly to all users is risky. A mechanism was needed to let early adopters test pre-release builds and to gate experimental features behind flags that can be toggled without a new release.
|
||||
|
||||
[[Keyboard-first design principle]]
|
||||
|
||||
## Decision
|
||||
|
||||
**Add a canary release channel alongside stable, with builds from the `canary` branch. Feature flags are localStorage-based (`ff_<name>`) with compile-time defaults, checked via `useFeatureFlag(flag)` hook. The update channel is configurable in Settings.**
|
||||
**Add a canary release channel alongside stable, with builds from the **`canary`** branch. Feature flags are localStorage-based (**`ff_<name>`**) with compile-time defaults, checked via **`useFeatureFlag(flag)`** hook. The update channel is configurable in Settings.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages.
|
||||
- **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency.
|
||||
- **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds.
|
||||
* **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages.
|
||||
* **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency.
|
||||
* **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch.
|
||||
- Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease.
|
||||
- `useUpdater(channel)` checks the appropriate update manifest.
|
||||
- `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union.
|
||||
- `update_channel` stored in Settings as `"stable"` or `"canary"`.
|
||||
- Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts.
|
||||
* `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch.
|
||||
* Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease.
|
||||
* `useUpdater(channel)` checks the appropriate update manifest.
|
||||
* `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union.
|
||||
* `update_channel` stored in Settings as `"stable"` or `"canary"`.
|
||||
* Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts.
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
|
||||
<title>laputa-scaffold</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@mantine/core": "^8.3.14",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.4)
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3
|
||||
'@mantine/core':
|
||||
specifier: ^8.3.14
|
||||
version: 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
||||
20
src-tauri/Cargo.lock
generated
20
src-tauri/Cargo.lock
generated
@@ -2272,6 +2272,7 @@ dependencies = [
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
@@ -4294,6 +4295,19 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -5509,6 +5523,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -18,6 +18,7 @@ tauri-build = { version = "2.5.4", features = [] }
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
log = "0.4"
|
||||
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools"] }
|
||||
tauri-plugin-log = "2"
|
||||
|
||||
@@ -128,6 +128,22 @@ pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, St
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn is_git_repo(vault_path: String) -> bool {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
std::path::Path::new(vault_path.as_ref())
|
||||
.join(".git")
|
||||
.is_dir()
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::init_repo(&vault_path)
|
||||
}
|
||||
|
||||
// ── Git commands (mobile stubs) ─────────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -230,3 +246,15 @@ pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, S
|
||||
behind: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn is_git_repo(_vault_path: String) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(_vault_path: String) -> Result<(), String> {
|
||||
Err("Git init is not available on mobile".into())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::search::SearchResponse;
|
||||
use crate::vault::{RenameResult, VaultEntry};
|
||||
use crate::vault::{
|
||||
DetectedRename, FolderNode, RenameResult, VaultEntry, ViewDefinition, ViewFile,
|
||||
};
|
||||
use crate::{frontmatter, git, search, vault};
|
||||
|
||||
use super::expand_tilde;
|
||||
@@ -13,6 +15,12 @@ pub fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vault_folders(path: String) -> Result<Vec<FolderNode>, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::scan_vault_folders(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_note_content(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
@@ -37,6 +45,21 @@ pub fn rename_note(
|
||||
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::detect_renames(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_wikilinks_for_renames(
|
||||
vault_path: String,
|
||||
renames: Vec<DetectedRename>,
|
||||
) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::update_wikilinks_for_renames(&vault_path, &renames)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -68,15 +91,14 @@ pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn flatten_vault(vault_path: String) -> Result<usize, String> {
|
||||
pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::flatten_vault(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn vault_health_check(vault_path: String) -> Result<vault::VaultHealthReport, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::vault_health_check(&vault_path)
|
||||
let folder_path = std::path::Path::new(vault_path.as_ref()).join(&folder_name);
|
||||
if folder_path.exists() {
|
||||
return Err(format!("Folder '{}' already exists", folder_name));
|
||||
}
|
||||
std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?;
|
||||
Ok(folder_name)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -138,6 +160,30 @@ pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<St
|
||||
vault::copy_image_to_vault(&vault_path, &source_path)
|
||||
}
|
||||
|
||||
// ── View commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_views(vault_path: String) -> Vec<ViewFile> {
|
||||
let path = expand_tilde(&vault_path);
|
||||
vault::scan_views(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_view_cmd(
|
||||
vault_path: String,
|
||||
filename: String,
|
||||
definition: ViewDefinition,
|
||||
) -> Result<(), String> {
|
||||
let path = expand_tilde(&vault_path);
|
||||
vault::save_view(std::path::Path::new(path.as_ref()), &filename, &definition)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> {
|
||||
let path = expand_tilde(&vault_path);
|
||||
vault::delete_view(std::path::Path::new(path.as_ref()), &filename)
|
||||
}
|
||||
|
||||
// ── Frontmatter commands ────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -161,7 +207,7 @@ pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(&path, "_archived", FrontmatterValue::Bool(true))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
@@ -173,10 +219,10 @@ pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(&path, "_trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(
|
||||
&path,
|
||||
"Trashed at",
|
||||
"_trashed_at",
|
||||
FrontmatterValue::String(now.clone()),
|
||||
)?;
|
||||
count += 1;
|
||||
@@ -206,7 +252,6 @@ pub async fn search_vault(
|
||||
pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::migrate_is_a_to_type(&vault_path)?;
|
||||
vault::flatten_vault(&vault_path)?;
|
||||
vault::repair_config_files(&vault_path)?;
|
||||
git::ensure_gitignore(&vault_path)?;
|
||||
Ok("Vault repaired".to_string())
|
||||
@@ -231,7 +276,7 @@ mod tests {
|
||||
1
|
||||
);
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("Archived: true"));
|
||||
assert!(content.contains("_archived: true"));
|
||||
assert!(content.contains("Status: Active"));
|
||||
}
|
||||
|
||||
@@ -243,8 +288,8 @@ mod tests {
|
||||
1
|
||||
);
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("Trashed: true"));
|
||||
assert!(content.contains("Trashed at"));
|
||||
assert!(content.contains("_trashed: true"));
|
||||
assert!(content.contains("_trashed_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -341,7 +386,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_vault_flattens_type_folders() {
|
||||
fn test_repair_vault_migrates_is_a_to_type() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path();
|
||||
let note_dir = vault_path.join("note");
|
||||
@@ -350,9 +395,8 @@ mod tests {
|
||||
|
||||
let result = repair_vault(vault_path.to_str().unwrap().to_string());
|
||||
assert!(result.is_ok());
|
||||
assert!(vault_path.join("hello.md").exists());
|
||||
assert!(!note_dir.join("hello.md").exists());
|
||||
let content = std::fs::read_to_string(vault_path.join("hello.md")).unwrap();
|
||||
assert!(note_dir.join("hello.md").exists());
|
||||
let content = std::fs::read_to_string(note_dir.join("hello.md")).unwrap();
|
||||
assert!(content.contains("type: Note"));
|
||||
assert!(!content.contains("is_a:"));
|
||||
}
|
||||
|
||||
230
src-tauri/src/git/dates.rs
Normal file
230
src-tauri/src/git/dates.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use chrono::DateTime;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Git-derived creation and modification timestamps for a file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitDates {
|
||||
pub created_at: u64,
|
||||
pub modified_at: u64,
|
||||
}
|
||||
|
||||
/// Run a single `git log` to collect creation and modification dates for all
|
||||
/// tracked files in the repository. Returns a map from relative path to dates.
|
||||
///
|
||||
/// - **modified_at** = author date of the most recent commit touching the file
|
||||
/// - **created_at** = author date of the oldest commit touching the file
|
||||
///
|
||||
/// Files not yet committed (untracked / only staged) will not appear in the map;
|
||||
/// callers should fall back to filesystem metadata for those.
|
||||
pub fn get_all_file_dates(vault_path: &Path) -> HashMap<String, GitDates> {
|
||||
let output = match Command::new("git")
|
||||
.args(["log", "--format=COMMIT %aI", "--name-only"])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
{
|
||||
Ok(o) if o.status.success() => o,
|
||||
_ => return HashMap::new(),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_git_log_output(&stdout)
|
||||
}
|
||||
|
||||
/// Parse the output of `git log --format="COMMIT %aI" --name-only`.
|
||||
///
|
||||
/// Output looks like:
|
||||
/// ```text
|
||||
/// COMMIT 2026-03-15T10:00:00+02:00
|
||||
///
|
||||
/// file-a.md
|
||||
/// file-b.md
|
||||
///
|
||||
/// COMMIT 2026-03-10T08:00:00+02:00
|
||||
///
|
||||
/// file-a.md
|
||||
/// ```
|
||||
///
|
||||
/// Commits are ordered newest-first. For each file:
|
||||
/// - First occurrence → sets `modified_at`
|
||||
/// - Every subsequent occurrence overwrites `created_at` (last one = oldest commit wins)
|
||||
fn parse_git_log_output(stdout: &str) -> HashMap<String, GitDates> {
|
||||
let mut map: HashMap<String, GitDates> = HashMap::new();
|
||||
let mut current_ts: Option<u64> = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if let Some(date_str) = line.strip_prefix("COMMIT ") {
|
||||
current_ts = parse_author_date(date_str);
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = line.trim();
|
||||
if path.is_empty() || current_ts.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Only process .md files
|
||||
if !path.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = current_ts.unwrap();
|
||||
map.entry(path.to_string())
|
||||
.and_modify(|d| d.created_at = ts)
|
||||
.or_insert(GitDates {
|
||||
created_at: ts,
|
||||
modified_at: ts,
|
||||
});
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
fn parse_author_date(s: &str) -> Option<u64> {
|
||||
DateTime::parse_from_rfc3339(s.trim())
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp() as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_log_single_commit() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
file-a.md
|
||||
file-b.md
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert_eq!(map["file-a.md"].created_at, 1773568800);
|
||||
assert_eq!(map["file-a.md"].modified_at, 1773568800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_log_multiple_commits() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
file-a.md
|
||||
|
||||
COMMIT 2026-03-10T08:00:00+00:00
|
||||
|
||||
file-a.md
|
||||
file-b.md
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 2);
|
||||
// file-a: modified = newest (2026-03-15), created = oldest (2026-03-10)
|
||||
assert_eq!(map["file-a.md"].modified_at, 1773568800);
|
||||
assert_eq!(map["file-a.md"].created_at, 1773129600);
|
||||
// file-b: only in second commit
|
||||
assert_eq!(map["file-b.md"].modified_at, 1773129600);
|
||||
assert_eq!(map["file-b.md"].created_at, 1773129600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_md_files_filtered_out() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
README.txt
|
||||
note.md
|
||||
image.png
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 1);
|
||||
assert!(map.contains_key("note.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_output() {
|
||||
let map = parse_git_log_output("");
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subdirectory_paths() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
docs/adr/0001-stack.md
|
||||
notes/daily.md
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert!(map.contains_key("docs/adr/0001-stack.md"));
|
||||
assert!(map.contains_key("notes/daily.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_file_dates_in_real_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// First commit with one file
|
||||
std::fs::write(vault.join("first.md"), "# First\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "first"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Second commit with another file + modify first
|
||||
std::fs::write(vault.join("first.md"), "# First\nUpdated.\n").unwrap();
|
||||
std::fs::write(vault.join("second.md"), "# Second\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "second"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let map = get_all_file_dates(vault);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert!(map.contains_key("first.md"));
|
||||
assert!(map.contains_key("second.md"));
|
||||
|
||||
// first.md: created in commit 1, modified in commit 2
|
||||
// So modified_at > created_at (or equal if commits are same second)
|
||||
assert!(map["first.md"].modified_at >= map["first.md"].created_at);
|
||||
// second.md: only in commit 2
|
||||
assert_eq!(map["second.md"].modified_at, map["second.md"].created_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_file_dates_no_git_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let map = get_all_file_dates(dir.path());
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod commit;
|
||||
mod conflict;
|
||||
mod dates;
|
||||
mod history;
|
||||
mod pulse;
|
||||
mod remote;
|
||||
@@ -13,6 +14,7 @@ pub use conflict::{
|
||||
get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict,
|
||||
is_merge_in_progress, is_rebase_in_progress,
|
||||
};
|
||||
pub use dates::{get_all_file_dates, GitDates};
|
||||
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
|
||||
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
|
||||
pub use remote::{
|
||||
|
||||
@@ -117,11 +117,14 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::list_vault,
|
||||
commands::list_vault_folders,
|
||||
commands::get_note_content,
|
||||
commands::save_note_content,
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::detect_renames,
|
||||
commands::update_wikilinks_for_renames,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
@@ -137,6 +140,8 @@ pub fn run() {
|
||||
commands::get_conflict_mode,
|
||||
commands::git_resolve_conflict,
|
||||
commands::git_commit_conflict_resolution,
|
||||
commands::is_git_repo,
|
||||
commands::init_git_repo,
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
@@ -150,8 +155,7 @@ pub fn run() {
|
||||
commands::batch_delete_notes,
|
||||
commands::empty_trash,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::flatten_vault,
|
||||
commands::vault_health_check,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::batch_trash_notes,
|
||||
commands::get_settings,
|
||||
@@ -172,7 +176,10 @@ pub fn run() {
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -295,7 +295,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
|
||||
.id(VIEW_TOGGLE_AI_CHAT)
|
||||
.accelerator("CmdOrCtrl+I")
|
||||
.accelerator("CmdOrCtrl+Alt+I")
|
||||
.build(app)?;
|
||||
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
|
||||
.id(VIEW_TOGGLE_BACKLINKS)
|
||||
|
||||
@@ -3,12 +3,15 @@ use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{parse_md_file, scan_vault, VaultEntry};
|
||||
use crate::git::{get_all_file_dates, GitDates};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry};
|
||||
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 8;
|
||||
const CACHE_VERSION: u32 = 11;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
@@ -172,14 +175,26 @@ fn to_relative_path(abs_path: &str, vault: &Path) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse .md files from a list of relative paths, skipping any that don't exist.
|
||||
fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
/// Parse files from a list of relative paths, skipping any that don't exist.
|
||||
/// Dispatches to the appropriate parser based on file extension.
|
||||
fn parse_files_at(
|
||||
vault: &Path,
|
||||
rel_paths: &[String],
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Vec<VaultEntry> {
|
||||
rel_paths
|
||||
.iter()
|
||||
.filter_map(|rel| {
|
||||
let abs = vault.join(rel);
|
||||
if abs.is_file() {
|
||||
parse_md_file(&abs).ok()
|
||||
let dates = git_dates
|
||||
.get(rel.as_str())
|
||||
.map(|d| (d.modified_at, d.created_at));
|
||||
if is_md_file(&abs) {
|
||||
parse_md_file(&abs, dates).ok()
|
||||
} else {
|
||||
parse_non_md_file(&abs, dates).ok()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -264,13 +279,17 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
|
||||
/// Always prunes stale entries even when git reports no changes, so that files
|
||||
/// deleted outside git (e.g., via Finder) are removed from the cache on vault open.
|
||||
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
||||
fn update_same_commit(
|
||||
vault: &Path,
|
||||
cache: VaultCache,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Vec<VaultEntry> {
|
||||
let changed = git_uncommitted_files(vault);
|
||||
let mut entries = cache.entries;
|
||||
if !changed.is_empty() {
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault)));
|
||||
entries.extend(parse_files_at(vault, &changed));
|
||||
entries.extend(parse_files_at(vault, &changed, git_dates));
|
||||
}
|
||||
// Always finalize: prune_stale_entries inside finalize_and_cache removes
|
||||
// entries for files deleted outside git (e.g., via Finder or another app).
|
||||
@@ -282,6 +301,7 @@ fn update_different_commit(
|
||||
vault: &Path,
|
||||
cache: VaultCache,
|
||||
current_hash: String,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Vec<VaultEntry> {
|
||||
let changed_files = git_changed_files(vault, &cache.commit_hash, ¤t_hash);
|
||||
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
|
||||
@@ -291,7 +311,7 @@ fn update_different_commit(
|
||||
.into_iter()
|
||||
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
|
||||
.collect();
|
||||
entries.extend(parse_files_at(vault, &changed_files));
|
||||
entries.extend(parse_files_at(vault, &changed_files, git_dates));
|
||||
|
||||
finalize_and_cache(vault, entries, current_hash)
|
||||
}
|
||||
@@ -319,26 +339,34 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
|
||||
let current_hash = match git_head_hash(vault_path) {
|
||||
Some(h) => h,
|
||||
None => return scan_vault(vault_path),
|
||||
None => return scan_vault(vault_path, &HashMap::new()),
|
||||
};
|
||||
|
||||
// Build git dates map once — used by all code paths below
|
||||
let git_dates = get_all_file_dates(vault_path);
|
||||
|
||||
if let Some(cache) = load_cache(vault_path) {
|
||||
let current_vault_str = vault_path.to_string_lossy();
|
||||
let cache_stale = cache.version != CACHE_VERSION
|
||||
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref());
|
||||
if cache_stale {
|
||||
let entries = scan_vault(vault_path)?;
|
||||
let entries = scan_vault(vault_path, &git_dates)?;
|
||||
return Ok(finalize_and_cache(vault_path, entries, current_hash));
|
||||
}
|
||||
return if cache.commit_hash == current_hash {
|
||||
Ok(update_same_commit(vault_path, cache))
|
||||
Ok(update_same_commit(vault_path, cache, &git_dates))
|
||||
} else {
|
||||
Ok(update_different_commit(vault_path, cache, current_hash))
|
||||
Ok(update_different_commit(
|
||||
vault_path,
|
||||
cache,
|
||||
current_hash,
|
||||
&git_dates,
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
// No cache — full scan and write cache
|
||||
let entries = scan_vault(vault_path)?;
|
||||
let entries = scan_vault(vault_path, &git_dates)?;
|
||||
Ok(finalize_and_cache(vault_path, entries, current_hash))
|
||||
}
|
||||
|
||||
@@ -930,7 +958,7 @@ mod tests {
|
||||
|
||||
// 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();
|
||||
let mut e = parse_md_file(&vault.join("note.md"), None).unwrap();
|
||||
e.archived = false; // simulate old parser behavior
|
||||
e
|
||||
};
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A node in the vault's folder tree. Only contains directories, not files.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FolderNode {
|
||||
/// Folder name (last path component).
|
||||
pub name: String,
|
||||
/// Path relative to the vault root, using `/` separators (e.g. "projects/laputa").
|
||||
pub path: String,
|
||||
/// Child folders (sorted alphabetically).
|
||||
pub children: Vec<FolderNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct VaultEntry {
|
||||
pub path: String,
|
||||
@@ -48,6 +59,11 @@ pub struct VaultEntry {
|
||||
pub view: Option<String>,
|
||||
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
|
||||
pub visible: Option<bool>,
|
||||
/// Whether this note is a user favorite (shown in FAVORITES sidebar section).
|
||||
pub favorite: bool,
|
||||
/// Display order within the FAVORITES section (lower = higher).
|
||||
#[serde(rename = "favoriteIndex")]
|
||||
pub favorite_index: Option<i64>,
|
||||
/// Word count of the note body (excludes frontmatter and H1 title).
|
||||
#[serde(rename = "wordCount")]
|
||||
pub word_count: u32,
|
||||
@@ -59,4 +75,12 @@ pub struct VaultEntry {
|
||||
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
/// File kind: "markdown", "text", or "binary".
|
||||
/// Determines how the frontend renders and opens the file.
|
||||
#[serde(rename = "fileKind", default = "default_file_kind")]
|
||||
pub file_kind: String,
|
||||
}
|
||||
|
||||
fn default_file_kind() -> String {
|
||||
"markdown".to_string()
|
||||
}
|
||||
|
||||
@@ -12,14 +12,16 @@ pub(crate) struct Frontmatter {
|
||||
#[serde(default)]
|
||||
pub aliases: Option<StringOrList>,
|
||||
#[serde(
|
||||
rename = "Archived",
|
||||
rename = "_archived",
|
||||
alias = "Archived",
|
||||
alias = "archived",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub archived: Option<bool>,
|
||||
#[serde(
|
||||
rename = "Trashed",
|
||||
rename = "_trashed",
|
||||
alias = "Trashed",
|
||||
alias = "trashed",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
@@ -27,7 +29,7 @@ pub(crate) struct Frontmatter {
|
||||
pub trashed: Option<bool>,
|
||||
#[serde(rename = "Status", alias = "status", default)]
|
||||
pub status: Option<StringOrList>,
|
||||
#[serde(rename = "Trashed at", alias = "trashed_at")]
|
||||
#[serde(rename = "_trashed_at", alias = "Trashed at", alias = "trashed_at")]
|
||||
pub trashed_at: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<StringOrList>,
|
||||
@@ -45,6 +47,14 @@ pub(crate) struct Frontmatter {
|
||||
pub view: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub visible: Option<bool>,
|
||||
#[serde(
|
||||
rename = "_favorite",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub favorite: Option<bool>,
|
||||
#[serde(rename = "_favorite_index", default)]
|
||||
pub favorite_index: Option<i64>,
|
||||
}
|
||||
|
||||
/// Custom deserializer for boolean fields that may arrive as strings.
|
||||
@@ -137,10 +147,13 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"Is A",
|
||||
"is_a",
|
||||
"aliases",
|
||||
"_archived",
|
||||
"Archived",
|
||||
"archived",
|
||||
"_trashed",
|
||||
"Trashed",
|
||||
"trashed",
|
||||
"_trashed_at",
|
||||
"Trashed at",
|
||||
"trashed_at",
|
||||
"icon",
|
||||
@@ -154,6 +167,8 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"notion_id",
|
||||
"Status",
|
||||
"status",
|
||||
"_favorite",
|
||||
"_favorite_index",
|
||||
];
|
||||
let filtered: serde_json::Map<String, serde_json::Value> = data
|
||||
.iter()
|
||||
@@ -172,9 +187,13 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"is a",
|
||||
"type",
|
||||
"aliases",
|
||||
"_archived",
|
||||
"archived",
|
||||
"_trashed",
|
||||
"trashed",
|
||||
"_trashed_at",
|
||||
"trashed at",
|
||||
"trashed_at",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
@@ -184,6 +203,8 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"view",
|
||||
"visible",
|
||||
"status",
|
||||
"_favorite",
|
||||
"_favorite_index",
|
||||
];
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
@@ -284,20 +305,120 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip matching outer quotes (single or double) from a YAML scalar.
|
||||
fn unquote(s: &str) -> &str {
|
||||
s.strip_prefix('"')
|
||||
.and_then(|rest| rest.strip_suffix('"'))
|
||||
.or_else(|| {
|
||||
s.strip_prefix('\'')
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
})
|
||||
.unwrap_or(s)
|
||||
}
|
||||
|
||||
/// Parse a scalar YAML value into a JSON value.
|
||||
fn parse_scalar(s: &str) -> serde_json::Value {
|
||||
let trimmed = unquote(s);
|
||||
match trimmed.to_lowercase().as_str() {
|
||||
"true" | "yes" => serde_json::Value::Bool(true),
|
||||
"false" | "no" => serde_json::Value::Bool(false),
|
||||
_ => trimmed
|
||||
.parse::<i64>()
|
||||
.map(|n| serde_json::json!(n))
|
||||
.unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the key from a top-level `key:` or `"key":` YAML line.
|
||||
/// Returns `None` for indented, blank, or non-key lines.
|
||||
fn extract_yaml_key(line: &str) -> Option<&str> {
|
||||
if line.is_empty() || line.starts_with(' ') || line.starts_with('\t') {
|
||||
return None;
|
||||
}
|
||||
let (k, _) = line.split_once(':')?;
|
||||
Some(k.trim().trim_matches('"'))
|
||||
}
|
||||
|
||||
/// Flush a pending list accumulator into the map.
|
||||
fn flush_list(
|
||||
map: &mut HashMap<String, serde_json::Value>,
|
||||
key: &mut Option<String>,
|
||||
items: &mut Vec<serde_json::Value>,
|
||||
) {
|
||||
if let Some(k) = key.take() {
|
||||
if !items.is_empty() {
|
||||
map.insert(k, serde_json::Value::Array(std::mem::take(items)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback parser for when gray_matter fails to parse YAML (returns raw string).
|
||||
/// Extracts simple `key: value` lines, handling booleans, numbers, quoted strings,
|
||||
/// and YAML lists.
|
||||
fn fallback_parse_yaml_string(raw: &str) -> HashMap<String, serde_json::Value> {
|
||||
let mut map = HashMap::new();
|
||||
let mut list_key: Option<String> = None;
|
||||
let mut list_items: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
// Accumulate list items under the current key
|
||||
if list_key.is_some() {
|
||||
if let Some(item) = line.strip_prefix(" - ") {
|
||||
list_items.push(parse_scalar(item.trim()));
|
||||
continue;
|
||||
}
|
||||
flush_list(&mut map, &mut list_key, &mut list_items);
|
||||
}
|
||||
|
||||
let Some(key) = extract_yaml_key(line) else {
|
||||
continue;
|
||||
};
|
||||
let value_part = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or("");
|
||||
if value_part.is_empty() {
|
||||
list_key = Some(key.to_string());
|
||||
} else {
|
||||
map.insert(key.to_string(), parse_scalar(value_part));
|
||||
}
|
||||
}
|
||||
flush_list(&mut map, &mut list_key, &mut list_items);
|
||||
map
|
||||
}
|
||||
|
||||
/// Extract the raw YAML frontmatter string from between `---` delimiters.
|
||||
fn extract_raw_frontmatter(content: &str) -> Option<&str> {
|
||||
let rest = content.strip_prefix("---")?;
|
||||
let rest = rest
|
||||
.strip_prefix('\n')
|
||||
.or_else(|| rest.strip_prefix("\r\n"))?;
|
||||
let end = rest.find("\n---")?;
|
||||
Some(&rest[..end])
|
||||
}
|
||||
|
||||
/// Extract frontmatter, relationships, and custom properties from parsed gray_matter data.
|
||||
/// When gray_matter fails to parse YAML (e.g. malformed quotes from Notion exports),
|
||||
/// `raw_content` is used as a fallback: simple key:value pairs are extracted line-by-line
|
||||
/// so that critical fields like Trashed, Archived, type are not silently lost.
|
||||
pub(crate) fn extract_fm_and_rels(
|
||||
data: Option<gray_matter::Pod>,
|
||||
raw_content: &str,
|
||||
) -> (
|
||||
Frontmatter,
|
||||
HashMap<String, Vec<String>>,
|
||||
HashMap<String, serde_json::Value>,
|
||||
) {
|
||||
let hash = match data {
|
||||
Some(gray_matter::Pod::Hash(map)) => map,
|
||||
_ => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
|
||||
let json_map = match data {
|
||||
Some(gray_matter::Pod::Hash(map)) => {
|
||||
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect()
|
||||
}
|
||||
_ => {
|
||||
// gray_matter returned Null, String, or None — YAML parse failed.
|
||||
// Fall back to line-by-line extraction from the raw frontmatter block.
|
||||
match extract_raw_frontmatter(raw_content) {
|
||||
Some(raw) => fallback_parse_yaml_string(raw),
|
||||
None => return (Frontmatter::default(), HashMap::new(), HashMap::new()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let json_map: HashMap<String, serde_json::Value> =
|
||||
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
|
||||
(
|
||||
parse_frontmatter(&json_map),
|
||||
extract_relationships(&json_map),
|
||||
|
||||
@@ -503,7 +503,8 @@ mod tests {
|
||||
let vault_path = dir.path().join("parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entries = crate::vault::scan_vault(&vault_path).unwrap();
|
||||
let entries =
|
||||
crate::vault::scan_vault(&vault_path, &std::collections::HashMap::new()).unwrap();
|
||||
// SAMPLE_FILES + AGENTS.md
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1);
|
||||
}
|
||||
@@ -537,7 +538,7 @@ mod tests {
|
||||
let vault_path = dir.path().join("agents-parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md"), None).unwrap();
|
||||
// No frontmatter title → derived from filename slug (H1 is body content)
|
||||
assert_eq!(entry.title, "AGENTS");
|
||||
// Config files have no frontmatter type field — type is None
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
@@ -118,311 +115,6 @@ pub fn migrate_is_a_to_type(vault_path: &str) -> Result<usize, String> {
|
||||
Ok(migrated)
|
||||
}
|
||||
|
||||
/// Folders that are NOT flattened — they contain assets, not notes.
|
||||
const KEEP_FOLDERS: &[&str] = &["attachments", "assets"];
|
||||
|
||||
/// Determine a unique filename at `dest_dir`, appending -2, -3, etc. on collision.
|
||||
fn unique_filename(dest_dir: &Path, filename: &str, taken: &HashSet<String>) -> String {
|
||||
if !dest_dir.join(filename).exists() && !taken.contains(filename) {
|
||||
return filename.to_string();
|
||||
}
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let ext = Path::new(filename)
|
||||
.extension()
|
||||
.map(|s| format!(".{}", s.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let mut counter = 2;
|
||||
loop {
|
||||
let candidate = format!("{}-{}{}", stem, counter, ext);
|
||||
if !dest_dir.join(&candidate).exists() && !taken.contains(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten vault structure: move all notes from type-based subfolders to the vault root.
|
||||
/// Skips `type/`, `config/`, `attachments/`, and `_themes/` folders.
|
||||
/// Updates path-based wikilinks to title-based after moving.
|
||||
/// Returns the number of files moved.
|
||||
pub fn flatten_vault(vault_path: &str) -> Result<usize, 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
|
||||
));
|
||||
}
|
||||
|
||||
// Collect all .md files in subfolders (not already at root, not in KEEP_FOLDERS)
|
||||
let mut to_move: Vec<(std::path::PathBuf, String)> = Vec::new();
|
||||
for entry in WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
// Skip files already at vault root
|
||||
if path.parent() == Some(vault) {
|
||||
continue;
|
||||
}
|
||||
// Check if this file is inside a KEEP_FOLDER
|
||||
let rel = path.strip_prefix(vault).unwrap_or(path);
|
||||
let top_folder = rel
|
||||
.components()
|
||||
.next()
|
||||
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if KEEP_FOLDERS.iter().any(|&k| k == top_folder) {
|
||||
continue;
|
||||
}
|
||||
// Hidden folders (e.g. .laputa, .git)
|
||||
if top_folder.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
to_move.push((path.to_path_buf(), filename));
|
||||
}
|
||||
|
||||
if to_move.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Build a map of old path → (new filename, old relative stem) for wikilink updates
|
||||
let mut taken: HashSet<String> = HashSet::new();
|
||||
// Pre-populate with files already at root
|
||||
if let Ok(entries) = fs::read_dir(vault) {
|
||||
for e in entries.flatten() {
|
||||
if e.path().is_file() {
|
||||
if let Some(name) = e.file_name().to_str() {
|
||||
taken.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let mut moves: Vec<(std::path::PathBuf, std::path::PathBuf, String)> = Vec::new(); // (old, new, old_rel_stem)
|
||||
for (old_path, filename) in &to_move {
|
||||
let new_name = unique_filename(vault, filename, &taken);
|
||||
taken.insert(new_name.clone());
|
||||
let new_path = vault.join(&new_name);
|
||||
let old_rel = old_path
|
||||
.to_string_lossy()
|
||||
.strip_prefix(&vault_prefix)
|
||||
.unwrap_or(&old_path.to_string_lossy())
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&old_path.to_string_lossy())
|
||||
.to_string();
|
||||
moves.push((old_path.clone(), new_path, old_rel));
|
||||
}
|
||||
|
||||
// Move all files
|
||||
let mut moved = 0;
|
||||
for (old, new, _) in &moves {
|
||||
match fs::rename(old, new) {
|
||||
Ok(()) => moved += 1,
|
||||
Err(e) => log::warn!(
|
||||
"Failed to move {} → {}: {}",
|
||||
old.display(),
|
||||
new.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Update path-based wikilinks across the vault.
|
||||
// Replace [[folder/slug]] with [[slug]] (title-based).
|
||||
// Build a single regex matching any old path stem.
|
||||
let path_stems: Vec<&str> = moves.iter().map(|(_, _, stem)| stem.as_str()).collect();
|
||||
if !path_stems.is_empty() {
|
||||
let escaped: Vec<String> = path_stems.iter().map(|s| regex::escape(s)).collect();
|
||||
let pattern_str = format!(r"\[\[({})\]\]", escaped.join("|"));
|
||||
if let Ok(re) = Regex::new(&pattern_str) {
|
||||
// Collect all .md files in vault (now at root + keep folders)
|
||||
let all_md: Vec<std::path::PathBuf> = WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md")
|
||||
})
|
||||
.map(|e| e.into_path())
|
||||
.collect();
|
||||
|
||||
for md_path in &all_md {
|
||||
let content = match fs::read_to_string(md_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !re.is_match(&content) {
|
||||
continue;
|
||||
}
|
||||
let replaced = re.replace_all(&content, |caps: ®ex::Captures| {
|
||||
let matched = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
// Extract just the filename stem (last segment)
|
||||
let slug = matched.rsplit('/').next().unwrap_or(matched);
|
||||
format!("[[{}]]", slug)
|
||||
});
|
||||
if replaced != content {
|
||||
let _ = fs::write(md_path, replaced.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up empty directories (only type-folder directories, not KEEP_FOLDERS)
|
||||
for (old, _, _) in &moves {
|
||||
if let Some(parent) = old.parent() {
|
||||
if parent != vault {
|
||||
let _ = fs::remove_dir(parent); // only succeeds if empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Result of a vault health check.
|
||||
#[derive(Debug, Serialize, Default)]
|
||||
pub struct VaultHealthReport {
|
||||
/// Files in non-protected subfolders (won't be scanned by scan_vault).
|
||||
pub stray_files: Vec<String>,
|
||||
/// Files whose filename doesn't match slugify(title).
|
||||
pub title_mismatches: Vec<TitleMismatch>,
|
||||
}
|
||||
|
||||
/// A single filename-title mismatch.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TitleMismatch {
|
||||
pub path: String,
|
||||
pub filename: String,
|
||||
pub title: String,
|
||||
pub expected_filename: String,
|
||||
}
|
||||
|
||||
/// Slugify a title to produce the expected filename stem.
|
||||
fn slugify(text: &str) -> String {
|
||||
let result: String = text
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let trimmed = result.trim_matches('-').to_string();
|
||||
// Collapse consecutive dashes
|
||||
let mut prev_dash = false;
|
||||
let collapsed: String = trimmed
|
||||
.chars()
|
||||
.filter(|&c| {
|
||||
if c == '-' {
|
||||
if prev_dash {
|
||||
return false;
|
||||
}
|
||||
prev_dash = true;
|
||||
} else {
|
||||
prev_dash = false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
if collapsed.is_empty() {
|
||||
"untitled".to_string()
|
||||
} else {
|
||||
collapsed
|
||||
}
|
||||
}
|
||||
|
||||
/// Check vault health: detect stray files and filename-title mismatches.
|
||||
pub fn vault_health_check(vault_path: &str) -> Result<VaultHealthReport, 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 mut report = VaultHealthReport::default();
|
||||
|
||||
// 1. Detect stray files in non-protected subfolders
|
||||
for entry in WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
// Skip root files (they're fine)
|
||||
if path.parent() == Some(vault) {
|
||||
continue;
|
||||
}
|
||||
let rel = path.strip_prefix(vault).unwrap_or(path);
|
||||
let top_folder = rel
|
||||
.components()
|
||||
.next()
|
||||
.map(|c| c.as_os_str().to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if KEEP_FOLDERS.iter().any(|&k| k == top_folder) || top_folder.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
report.stray_files.push(rel.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
// 2. Detect filename-title mismatches (root .md files only)
|
||||
if let Ok(dir_entries) = fs::read_dir(vault) {
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
for dir_entry in dir_entries.flatten() {
|
||||
let path = dir_entry.path();
|
||||
if !path.is_file() || path.extension().map(|ext| ext != "md").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let content = match fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let parsed = matter.parse(&content);
|
||||
let fm_title = parsed.data.as_ref().and_then(|pod| {
|
||||
if let gray_matter::Pod::Hash(ref map) = pod {
|
||||
if let Some(gray_matter::Pod::String(s)) = map.get("title") {
|
||||
return Some(s.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
let title = super::parsing::extract_title(fm_title, &content, &filename);
|
||||
let expected_stem = slugify(&title);
|
||||
let expected_filename = format!("{}.md", expected_stem);
|
||||
let current_stem = filename.strip_suffix(".md").unwrap_or(&filename);
|
||||
if current_stem != expected_stem {
|
||||
report.title_mismatches.push(TitleMismatch {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
filename: filename.clone(),
|
||||
title,
|
||||
expected_filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -561,226 +253,4 @@ mod tests {
|
||||
let count = migrate_is_a_to_type(tmp.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 0, "non-markdown files should be ignored");
|
||||
}
|
||||
|
||||
// --- flatten_vault ---
|
||||
|
||||
fn write_nested_file(
|
||||
dir: &std::path::Path,
|
||||
rel_path: &str,
|
||||
content: &str,
|
||||
) -> std::path::PathBuf {
|
||||
let path = dir.join(rel_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_moves_notes_to_root() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
|
||||
write_nested_file(
|
||||
vault,
|
||||
"project/my-proj.md",
|
||||
"---\ntype: Project\n---\n# My Proj\n",
|
||||
);
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 2);
|
||||
assert!(vault.join("hello.md").exists());
|
||||
assert!(vault.join("my-proj.md").exists());
|
||||
assert!(!vault.join("note/hello.md").exists());
|
||||
assert!(!vault.join("project/my-proj.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_skips_protected_folders() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(vault, "attachments/image.md", "# Image note\n");
|
||||
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert!(vault.join("hello.md").exists());
|
||||
assert!(vault.join("attachments/image.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_handles_filename_collision() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "hello.md", "---\ntype: Note\n---\n# Root Hello\n");
|
||||
write_nested_file(
|
||||
vault,
|
||||
"note/hello.md",
|
||||
"---\ntype: Note\n---\n# Note Hello\n",
|
||||
);
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert!(vault.join("hello.md").exists());
|
||||
assert!(vault.join("hello-2.md").exists());
|
||||
// Root file unchanged
|
||||
let root_content = fs::read_to_string(vault.join("hello.md")).unwrap();
|
||||
assert!(root_content.contains("Root Hello"));
|
||||
// Moved file gets suffixed name
|
||||
let moved_content = fs::read_to_string(vault.join("hello-2.md")).unwrap();
|
||||
assert!(moved_content.contains("Note Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_updates_path_wikilinks() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(
|
||||
vault,
|
||||
"note/hello.md",
|
||||
"---\ntype: Note\n---\n# Hello\n\nSee [[project/my-proj]] for details.\n",
|
||||
);
|
||||
write_nested_file(
|
||||
vault,
|
||||
"project/my-proj.md",
|
||||
"---\ntype: Project\n---\n# My Proj\n",
|
||||
);
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 2);
|
||||
let content = fs::read_to_string(vault.join("hello.md")).unwrap();
|
||||
assert!(content.contains("[[my-proj]]"));
|
||||
assert!(!content.contains("[[project/my-proj]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_noop_when_already_flat() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "hello.md", "---\ntype: Note\n---\n# Hello\n");
|
||||
write_file(vault, "world.md", "---\ntype: Note\n---\n# World\n");
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_nested_subfolders() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(vault, "note/sub/deep.md", "---\ntype: Note\n---\n# Deep\n");
|
||||
|
||||
let count = flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
assert!(vault.join("deep.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flatten_vault_cleans_empty_directories() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_nested_file(vault, "note/hello.md", "---\ntype: Note\n---\n# Hello\n");
|
||||
|
||||
flatten_vault(vault.to_str().unwrap()).unwrap();
|
||||
assert!(
|
||||
!vault.join("note").exists(),
|
||||
"empty folder should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
// --- slugify ---
|
||||
|
||||
#[test]
|
||||
fn test_slugify_basic() {
|
||||
assert_eq!(slugify("Hello World"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify_special_chars() {
|
||||
assert_eq!(slugify("My Note (v2)!"), "my-note-v2");
|
||||
assert_eq!(slugify("Sprint Retrospective"), "sprint-retrospective");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify_empty() {
|
||||
assert_eq!(slugify(""), "untitled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify_unicode() {
|
||||
assert_eq!(slugify("Café Résumé"), "caf-r-sum");
|
||||
}
|
||||
|
||||
// --- vault_health_check ---
|
||||
|
||||
#[test]
|
||||
fn test_health_check_detects_stray_files() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "root-note.md", "---\ntype: Note\n---\n# Root Note\n");
|
||||
write_file(vault, "project.md", "---\ntype: Type\n---\n# Project\n");
|
||||
write_nested_file(
|
||||
vault,
|
||||
"old-folder/stray.md",
|
||||
"---\ntype: Note\n---\n# Stray\n",
|
||||
);
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(report.stray_files.len(), 1);
|
||||
assert!(report.stray_files[0].contains("stray.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check_no_stray_when_flat() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "my-note.md", "# My Note\n");
|
||||
write_file(vault, "project.md", "---\ntype: Type\n---\n# Project\n");
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
assert!(report.stray_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check_detects_title_mismatch() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
// Filename is "wrong-name.md" but title frontmatter says "My Actual Title"
|
||||
write_file(
|
||||
vault,
|
||||
"wrong-name.md",
|
||||
"---\ntitle: My Actual Title\ntype: Note\n---\n# My Actual Title\n",
|
||||
);
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(report.title_mismatches.len(), 1);
|
||||
assert_eq!(report.title_mismatches[0].filename, "wrong-name.md");
|
||||
assert_eq!(
|
||||
report.title_mismatches[0].expected_filename,
|
||||
"my-actual-title.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check_no_mismatch_when_correct() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "my-note.md", "---\ntype: Note\n---\n# My Note\n");
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
assert!(report.title_mismatches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check_skips_hidden_folders() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let vault = tmp.path();
|
||||
write_file(vault, "root.md", "# Root\n");
|
||||
write_nested_file(vault, ".git/config.md", "# Git Config\n");
|
||||
write_nested_file(vault, ".laputa/cache.md", "# Cache\n");
|
||||
|
||||
let report = vault_health_check(vault.to_str().unwrap()).unwrap();
|
||||
assert!(report.stray_files.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,17 +10,24 @@ mod parsing;
|
||||
mod rename;
|
||||
mod title_sync;
|
||||
mod trash;
|
||||
mod views;
|
||||
|
||||
pub use cache::{invalidate_cache, scan_vault_cached};
|
||||
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
|
||||
pub use entry::VaultEntry;
|
||||
pub use entry::{FolderNode, VaultEntry};
|
||||
pub use file::{get_note_content, save_note_content};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::{flatten_vault, migrate_is_a_to_type, vault_health_check, VaultHealthReport};
|
||||
pub use rename::{rename_note, RenameResult};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{
|
||||
detect_renames, rename_note, update_wikilinks_for_renames, DetectedRename, RenameResult,
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
|
||||
pub use views::{
|
||||
delete_view, evaluate_view, save_view, scan_views, FilterCondition, FilterGroup, FilterNode,
|
||||
FilterOp, ViewDefinition, ViewFile,
|
||||
};
|
||||
|
||||
use file::read_file_metadata;
|
||||
use frontmatter::{extract_fm_and_rels, resolve_is_a};
|
||||
@@ -33,7 +40,11 @@ use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Parse a single markdown file into a VaultEntry.
|
||||
pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
///
|
||||
/// If `git_dates` is provided, those timestamps override filesystem metadata
|
||||
/// for `modified_at` and `created_at`. Pass `None` to use filesystem dates
|
||||
/// (appropriate for newly-saved files not yet committed, or non-git vaults).
|
||||
pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<VaultEntry, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
let filename = path
|
||||
@@ -43,13 +54,17 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&content);
|
||||
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data);
|
||||
let (frontmatter, mut relationships, properties) = extract_fm_and_rels(parsed.data, &content);
|
||||
|
||||
let title = extract_title(frontmatter.title.as_deref(), &content, &filename);
|
||||
let snippet = extract_snippet(&content);
|
||||
let word_count = count_body_words(&content);
|
||||
let outgoing_links = extract_outgoing_links(&parsed.content);
|
||||
let (modified_at, created_at, file_size) = read_file_metadata(path)?;
|
||||
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
|
||||
let (modified_at, created_at) = match git_dates {
|
||||
Some((git_mod, git_create)) => (Some(git_mod), Some(git_create)),
|
||||
None => (fs_modified, fs_created),
|
||||
};
|
||||
let is_a = resolve_is_a(frontmatter.is_a);
|
||||
|
||||
// Add "Type" relationship: isA becomes a navigable link to the type document.
|
||||
@@ -100,67 +115,249 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
sort: frontmatter.sort.and_then(|v| v.into_scalar()),
|
||||
view: frontmatter.view.and_then(|v| v.into_scalar()),
|
||||
visible: frontmatter.visible,
|
||||
favorite: frontmatter.favorite.unwrap_or(false),
|
||||
favorite_index: frontmatter.favorite_index,
|
||||
word_count,
|
||||
outgoing_links,
|
||||
properties,
|
||||
file_kind: "markdown".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a non-markdown file into a minimal VaultEntry.
|
||||
/// Uses filename as title, no frontmatter extraction.
|
||||
pub(crate) fn parse_non_md_file(
|
||||
path: &Path,
|
||||
git_dates: Option<(u64, u64)>,
|
||||
) -> Result<VaultEntry, String> {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
|
||||
let (modified_at, created_at) = match git_dates {
|
||||
Some((git_mod, git_create)) => (Some(git_mod), Some(git_create)),
|
||||
None => (fs_modified, fs_created),
|
||||
};
|
||||
let file_kind = classify_file_kind(path).to_string();
|
||||
|
||||
Ok(VaultEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
filename: filename.clone(),
|
||||
title: filename,
|
||||
file_kind,
|
||||
modified_at,
|
||||
created_at,
|
||||
file_size,
|
||||
..VaultEntry::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-read a single file from disk and return a fresh VaultEntry.
|
||||
/// Uses filesystem dates (no git lookup) since the file was likely just saved.
|
||||
pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("File does not exist: {}", path.display()));
|
||||
}
|
||||
parse_md_file(path)
|
||||
if is_md_file(path) {
|
||||
parse_md_file(path, None)
|
||||
} else {
|
||||
parse_non_md_file(path, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Folders that are scanned recursively (attachments, assets).
|
||||
/// All other subfolders are ignored — notes and type definitions live flat at the vault root.
|
||||
const PROTECTED_FOLDERS: &[&str] = &["attachments", "assets"];
|
||||
/// Directories that are never shown in the folder tree or scanned for notes.
|
||||
const HIDDEN_DIRS: &[&str] = &[".git", ".laputa", ".DS_Store"];
|
||||
|
||||
fn is_md_file(path: &Path) -> bool {
|
||||
fn is_hidden_dir(name: &str) -> bool {
|
||||
name.starts_with('.') || HIDDEN_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
pub(crate) fn is_md_file(path: &Path) -> bool {
|
||||
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
|
||||
}
|
||||
|
||||
fn try_parse_md(path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
match parse_md_file(path) {
|
||||
/// Extensions recognized as editable text files (opened in raw editor).
|
||||
const TEXT_EXTENSIONS: &[&str] = &[
|
||||
"yml",
|
||||
"yaml",
|
||||
"json",
|
||||
"txt",
|
||||
"toml",
|
||||
"csv",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"py",
|
||||
"rs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"rb",
|
||||
"go",
|
||||
"java",
|
||||
"kt",
|
||||
"c",
|
||||
"cpp",
|
||||
"h",
|
||||
"hpp",
|
||||
"swift",
|
||||
"lua",
|
||||
"sql",
|
||||
"graphql",
|
||||
"env",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"properties",
|
||||
"makefile",
|
||||
"dockerfile",
|
||||
"gitignore",
|
||||
"editorconfig",
|
||||
"mdx",
|
||||
"svelte",
|
||||
"vue",
|
||||
"astro",
|
||||
"tf",
|
||||
"hcl",
|
||||
"nix",
|
||||
"zig",
|
||||
"hs",
|
||||
"ml",
|
||||
"ex",
|
||||
"exs",
|
||||
"erl",
|
||||
"clj",
|
||||
"lisp",
|
||||
"el",
|
||||
"vim",
|
||||
"r",
|
||||
"jl",
|
||||
"ps1",
|
||||
"bat",
|
||||
"cmd",
|
||||
];
|
||||
|
||||
/// Classify a file extension into "markdown", "text", or "binary".
|
||||
pub(crate) fn classify_file_kind(path: &Path) -> &'static str {
|
||||
let ext = match path.extension() {
|
||||
Some(e) => e.to_string_lossy().to_lowercase(),
|
||||
None => {
|
||||
// Files without extension: check if name itself is a known text file
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
return if [
|
||||
"makefile",
|
||||
"dockerfile",
|
||||
"rakefile",
|
||||
"gemfile",
|
||||
"procfile",
|
||||
"brewfile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".env",
|
||||
]
|
||||
.contains(&name.as_str())
|
||||
{
|
||||
"text"
|
||||
} else {
|
||||
"binary"
|
||||
};
|
||||
}
|
||||
};
|
||||
if ext == "md" || ext == "markdown" {
|
||||
"markdown"
|
||||
} else if TEXT_EXTENSIONS.contains(&ext.as_str()) {
|
||||
"text"
|
||||
} else {
|
||||
"binary"
|
||||
}
|
||||
}
|
||||
|
||||
use crate::git::GitDates;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn lookup_git_dates(
|
||||
path: &Path,
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Option<(u64, u64)> {
|
||||
let rel = path
|
||||
.strip_prefix(vault_path)
|
||||
.ok()?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
git_dates.get(&rel).map(|d| (d.modified_at, d.created_at))
|
||||
}
|
||||
|
||||
fn try_parse_file(
|
||||
path: &Path,
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
entries: &mut Vec<VaultEntry>,
|
||||
) {
|
||||
let dates = lookup_git_dates(path, vault_path, git_dates);
|
||||
let result = if is_md_file(path) {
|
||||
parse_md_file(path, dates)
|
||||
} else {
|
||||
parse_non_md_file(path, dates)
|
||||
};
|
||||
match result {
|
||||
Ok(vault_entry) => entries.push(vault_entry),
|
||||
Err(e) => log::warn!("Skipping file: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_root_md_files(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
let dir_entries = match fs::read_dir(vault_path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return,
|
||||
};
|
||||
for dir_entry in dir_entries.flatten() {
|
||||
let path = dir_entry.path();
|
||||
if is_md_file(&path) {
|
||||
try_parse_md(&path, entries);
|
||||
/// Scan all files in the vault, including subdirectories.
|
||||
/// Hidden directories (starting with `.`) are excluded.
|
||||
fn scan_all_files(
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
entries: &mut Vec<VaultEntry>,
|
||||
) {
|
||||
let walker = WalkDir::new(vault_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
if e.file_type().is_dir() {
|
||||
let name = e.file_name().to_string_lossy();
|
||||
// Skip the vault root itself (depth 0) — we only filter subdirs
|
||||
if e.depth() == 0 {
|
||||
return true;
|
||||
}
|
||||
return !is_hidden_dir(&name);
|
||||
}
|
||||
true
|
||||
});
|
||||
for entry in walker.filter_map(|e| e.ok()) {
|
||||
if entry.path().is_file() {
|
||||
// Skip hidden files (starting with '.') — e.g. .gitignore, .DS_Store
|
||||
let fname = entry.file_name().to_string_lossy();
|
||||
if fname.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
try_parse_file(entry.path(), vault_path, git_dates, entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_protected_folders(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
for folder in PROTECTED_FOLDERS {
|
||||
let folder_path = vault_path.join(folder);
|
||||
if !folder_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let md_files = WalkDir::new(&folder_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| is_md_file(e.path()));
|
||||
for entry in md_files {
|
||||
try_parse_md(entry.path(), entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a directory recursively for .md files and return VaultEntry for each.
|
||||
pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
/// Scan a directory recursively for all files and return VaultEntry for each.
|
||||
/// Pass an empty map for `git_dates` to use filesystem dates only.
|
||||
pub fn scan_vault(
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Result<Vec<VaultEntry>, String> {
|
||||
if !vault_path.exists() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist: {}",
|
||||
@@ -175,13 +372,51 @@ pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
scan_root_md_files(vault_path, &mut entries);
|
||||
scan_protected_folders(vault_path, &mut entries);
|
||||
scan_all_files(vault_path, git_dates, &mut entries);
|
||||
|
||||
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Build a tree of visible folders in the vault.
|
||||
/// Excludes hidden directories (starting with `.`).
|
||||
pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
|
||||
if !vault_path.is_dir() {
|
||||
return Err(format!("Not a directory: {}", vault_path.display()));
|
||||
}
|
||||
fn build_tree(dir: &Path, vault_root: &Path) -> Vec<FolderNode> {
|
||||
let mut nodes: Vec<FolderNode> = Vec::new();
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return nodes,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if is_hidden_dir(&name) {
|
||||
continue;
|
||||
}
|
||||
let rel_path = path
|
||||
.strip_prefix(vault_root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let children = build_tree(&path, vault_root);
|
||||
nodes.push(FolderNode {
|
||||
name,
|
||||
path: rel_path,
|
||||
children,
|
||||
});
|
||||
}
|
||||
nodes.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
nodes
|
||||
}
|
||||
Ok(build_tree(vault_path, vault_path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -15,7 +15,7 @@ fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
|
||||
fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry {
|
||||
create_test_file(dir.path(), name, content);
|
||||
parse_md_file(&dir.path().join(name)).unwrap()
|
||||
parse_md_file(&dir.path().join(name), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -108,7 +108,7 @@ fn test_parse_no_frontmatter() {
|
||||
let content = "# A Note Without Frontmatter\n\nJust markdown.";
|
||||
create_test_file(dir.path(), "a-note-without-frontmatter.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md"), None).unwrap();
|
||||
// No title in frontmatter → derived from filename
|
||||
assert_eq!(entry.title, "A Note Without Frontmatter");
|
||||
}
|
||||
@@ -119,7 +119,7 @@ fn test_parse_single_string_aliases() {
|
||||
let content = "---\naliases: SingleAlias\n---\n# Test\n";
|
||||
create_test_file(dir.path(), "single-alias.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("single-alias.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("single-alias.md"), None).unwrap();
|
||||
assert_eq!(entry.aliases, vec!["SingleAlias"]);
|
||||
}
|
||||
|
||||
@@ -133,19 +133,31 @@ fn test_scan_vault_root_and_protected_folders() {
|
||||
"---\ntype: Type\n---\n# Project\n",
|
||||
);
|
||||
create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n");
|
||||
create_test_file(dir.path(), "not-markdown.txt", "This should be ignored");
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"not-markdown.txt",
|
||||
"This should be included as text",
|
||||
);
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 4);
|
||||
|
||||
let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect();
|
||||
assert!(filenames.contains(&"root.md"));
|
||||
assert!(filenames.contains(&"project.md"));
|
||||
assert!(filenames.contains(&"notes.md"));
|
||||
assert!(filenames.contains(&"not-markdown.txt"));
|
||||
|
||||
let txt_entry = entries
|
||||
.iter()
|
||||
.find(|e| e.filename == "not-markdown.txt")
|
||||
.unwrap();
|
||||
assert_eq!(txt_entry.file_kind, "text");
|
||||
assert_eq!(txt_entry.title, "not-markdown.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_skips_non_protected_subfolders() {
|
||||
fn test_scan_vault_includes_subdirectory_notes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "root.md", "# Root Note\n");
|
||||
create_test_file(
|
||||
@@ -159,9 +171,16 @@ fn test_scan_vault_skips_non_protected_subfolders() {
|
||||
"---\ntype: Project\n---\n# Old\n",
|
||||
);
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
assert_eq!(entries.len(), 1, "only root .md files should be scanned");
|
||||
assert_eq!(entries[0].filename, "root.md");
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
3,
|
||||
"all .md files including subdirs should be scanned"
|
||||
);
|
||||
let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect();
|
||||
assert!(filenames.contains(&"root.md"));
|
||||
assert!(filenames.contains(&"nested.md"));
|
||||
assert!(filenames.contains(&"old-project.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -171,7 +190,7 @@ fn test_scan_vault_includes_all_protected_folders() {
|
||||
create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n");
|
||||
create_test_file(dir.path(), "assets/image.md", "# Asset\n");
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
}
|
||||
|
||||
@@ -182,14 +201,17 @@ fn test_scan_vault_skips_hidden_folders() {
|
||||
create_test_file(dir.path(), ".laputa/cache.md", "# Cache\n");
|
||||
create_test_file(dir.path(), ".git/objects.md", "# Git\n");
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].filename, "root.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_nonexistent_path() {
|
||||
let result = scan_vault(Path::new("/nonexistent/path/that/does/not/exist"));
|
||||
let result = scan_vault(
|
||||
Path::new("/nonexistent/path/that/does/not/exist"),
|
||||
&HashMap::new(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -200,7 +222,7 @@ fn test_parse_malformed_yaml() {
|
||||
let content = "---\nIs A: [unclosed bracket\n---\n# Malformed\n";
|
||||
create_test_file(dir.path(), "malformed.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("malformed.md"));
|
||||
let entry = parse_md_file(&dir.path().join("malformed.md"), None);
|
||||
// Should still succeed — gray_matter may parse partially or skip
|
||||
assert!(entry.is_ok());
|
||||
}
|
||||
@@ -229,7 +251,7 @@ fn test_parse_md_file_has_snippet() {
|
||||
let content = "---\nIs A: Note\n---\n# Test Note\n\nHello, world! This is a snippet.";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.snippet, "Hello, world! This is a snippet.");
|
||||
}
|
||||
|
||||
@@ -240,7 +262,7 @@ fn test_parse_md_file_has_word_count() {
|
||||
"---\nIs A: Note\n---\n# Test Note\n\nHello world. This is a test with seven words.";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.word_count, 9);
|
||||
}
|
||||
|
||||
@@ -250,7 +272,7 @@ fn test_parse_md_file_word_count_empty_body() {
|
||||
let content = "---\nIs A: Note\n---\n# Empty Note\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.word_count, 0);
|
||||
}
|
||||
|
||||
@@ -271,7 +293,7 @@ Status: Active
|
||||
"#;
|
||||
create_test_file(dir.path(), "publish-essays.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("publish-essays.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("publish-essays.md"), None).unwrap();
|
||||
assert_eq!(entry.relationships.len(), 3); // Has, Topics, Type
|
||||
assert_eq!(
|
||||
entry.relationships.get("Has").unwrap(),
|
||||
@@ -303,7 +325,7 @@ Belongs to:
|
||||
"#;
|
||||
create_test_file(dir.path(), "some-project.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("some-project.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("some-project.md"), None).unwrap();
|
||||
|
||||
// Owner with wikilink should appear in relationships
|
||||
assert!(entry.relationships.get("Owner").is_some());
|
||||
@@ -335,7 +357,7 @@ Custom Field: just a plain string
|
||||
"#;
|
||||
create_test_file(dir.path(), "plain-note.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("plain-note.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("plain-note.md"), None).unwrap();
|
||||
// Tags and Custom Field don't contain wikilinks — only the auto-generated "Type" relationship
|
||||
assert_eq!(entry.relationships.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -397,7 +419,7 @@ Context: "[[area/research]]"
|
||||
"#;
|
||||
create_test_file(dir.path(), "single-vs-array.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("single-vs-array.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("single-vs-array.md"), None).unwrap();
|
||||
|
||||
// Single string → Vec with one element
|
||||
assert_eq!(
|
||||
@@ -474,7 +496,7 @@ References:
|
||||
"#;
|
||||
create_test_file(dir.path(), "mixed-array.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("mixed-array.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("mixed-array.md"), None).unwrap();
|
||||
|
||||
// Only the wikilink entries should be captured
|
||||
assert_eq!(
|
||||
@@ -538,7 +560,7 @@ title: No Code
|
||||
# No Code
|
||||
"#;
|
||||
create_test_file(dir.path(), "no-code.md", content);
|
||||
let entry = parse_md_file(&dir.path().join("no-code.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("no-code.md"), None).unwrap();
|
||||
|
||||
let notes = entry
|
||||
.relationships
|
||||
@@ -565,7 +587,7 @@ title: No Code
|
||||
fn test_type_from_frontmatter_only() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "test.md", "---\ntype: Custom\n---\n# Test\n");
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.is_a, Some("Custom".to_string()));
|
||||
}
|
||||
|
||||
@@ -573,7 +595,7 @@ fn test_type_from_frontmatter_only() {
|
||||
fn test_no_type_when_frontmatter_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "note/test.md", "# Test\n");
|
||||
let entry = parse_md_file(&dir.path().join("note/test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("note/test.md"), None).unwrap();
|
||||
assert_eq!(entry.is_a, None, "type should not be inferred from folder");
|
||||
}
|
||||
|
||||
@@ -585,7 +607,7 @@ fn test_created_at_from_filesystem() {
|
||||
let content = "---\nIs A: Note\n---\n# Test\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
// created_at should be set from filesystem metadata (not None)
|
||||
assert!(
|
||||
entry.created_at.is_some(),
|
||||
@@ -1070,6 +1092,54 @@ fn test_parse_trashed_no() {
|
||||
assert!(!entry.trashed, "'Trashed: No' must be parsed as false");
|
||||
}
|
||||
|
||||
// --- new canonical underscore-prefixed keys ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_underscore_trashed_canonical() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\n_trashed: true\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone-new.md", content);
|
||||
assert!(entry.trashed, "'_trashed: true' must be parsed as trashed");
|
||||
assert!(
|
||||
entry.trashed_at.is_some(),
|
||||
"'_trashed_at' must be parsed as trashed_at"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_underscore_archived_canonical() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\n_archived: true\n---\n# Old\n";
|
||||
let entry = parse_test_entry(&dir, "old-new.md", content);
|
||||
assert!(
|
||||
entry.archived,
|
||||
"'_archived: true' must be parsed as archived"
|
||||
);
|
||||
}
|
||||
|
||||
// --- favorite field tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_favorite_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\n_favorite: true\n_favorite_index: 3\n---\n# Fav\n";
|
||||
let entry = parse_test_entry(&dir, "fav.md", content);
|
||||
assert!(
|
||||
entry.favorite,
|
||||
"'_favorite: true' must be parsed as favorite"
|
||||
);
|
||||
assert_eq!(entry.favorite_index, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_favorite_absent_defaults_false() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\n---\n# Not Fav\n";
|
||||
let entry = parse_test_entry(&dir, "not-fav.md", content);
|
||||
assert!(!entry.favorite, "absent _favorite must default to false");
|
||||
assert_eq!(entry.favorite_index, None);
|
||||
}
|
||||
|
||||
// --- visible field tests ---
|
||||
|
||||
#[test]
|
||||
@@ -1096,6 +1166,142 @@ fn test_parse_visible_missing_defaults_to_none() {
|
||||
assert_eq!(entry.visible, None);
|
||||
}
|
||||
|
||||
// --- Regression: trashed/archived must survive unquoted date in "Trashed at" ---
|
||||
|
||||
#[test]
|
||||
fn test_trashed_true_with_unquoted_date_in_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Reproduces the engineering-management.md scenario: Trashed at has an
|
||||
// unquoted YAML date (2026-03-11) which gray_matter may parse as a non-string.
|
||||
// The entire Frontmatter deserialization must NOT fail because of this.
|
||||
let content = "---\ntype: Topic\nTrashed: true\n\"Trashed at\": 2026-03-11\n---\n# Engineering Management\n";
|
||||
let entry = parse_test_entry(&dir, "engineering-management.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when 'Trashed at' contains an unquoted date"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archived_true_with_extra_non_string_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// If any StringOrList field gets a non-string value, archived must still parse.
|
||||
let content = "---\nArchived: true\norder: 5\n---\n# Archived Note\n";
|
||||
let entry = parse_test_entry(&dir, "archived-extra.md", content);
|
||||
assert!(
|
||||
entry.archived,
|
||||
"Archived must be true even with other fields present"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trashed_with_reviewed_false_field() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Topic\nReviewed: False\nTrashed: true\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "reviewed-test.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when frontmatter contains Reviewed: False"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||||
}
|
||||
|
||||
/// Regression: wikilinks containing curly braces + nested quotes in YAML arrays
|
||||
/// cause gray_matter to produce Hash values instead of strings for array elements.
|
||||
/// This must NOT make parse_frontmatter fall back to default (losing trashed/archived).
|
||||
#[test]
|
||||
fn test_trashed_survives_malformed_wikilinks_in_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// This YAML has curly braces inside a double-quoted string, producing nested
|
||||
// Hash values in some YAML parsers. The Frontmatter serde must not fail.
|
||||
let content = "---\ntype: Topic\nNotes:\n - \"[[foo|bar]]\"\n - \"[[slug|{'Title': 'Subtitle'}]]\"\nTrashed: true\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "malformed-links.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even with curly-brace wikilinks in frontmatter arrays"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: files with malformed YAML (e.g. Notion exports with unescaped quotes
|
||||
/// in wikilinks) cause gray_matter to return Null instead of a Hash. The fallback
|
||||
/// parser must still extract Trashed, type, and other simple key:value fields.
|
||||
#[test]
|
||||
fn test_parse_real_engineering_management_file() {
|
||||
let path = std::path::Path::new("/Users/luca/Laputa/engineering-management.md");
|
||||
if !path.exists() {
|
||||
return; // Skip when the Laputa vault is not available
|
||||
}
|
||||
let entry = parse_md_file(path, None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"engineering-management.md must be trashed (has Trashed: true in frontmatter)"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_trashed_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Simulate malformed YAML that gray_matter can't parse: unescaped double
|
||||
// quotes inside a double-quoted YAML string cause the YAML parser to fail.
|
||||
// The fallback line-by-line parser must still extract simple key:value pairs.
|
||||
//
|
||||
// Write the file manually with literal unescaped quotes (can't use Rust string
|
||||
// escaping for this since the YAML itself is the malformed part).
|
||||
let fm = [
|
||||
"---",
|
||||
"type: Topic",
|
||||
"Status: Draft",
|
||||
"Belongs to:",
|
||||
" - \"[[engineering|Engineering]]\"",
|
||||
"aliases:",
|
||||
" - Engineering Management",
|
||||
"Notes:",
|
||||
// This line has unescaped " inside a "-quoted YAML string — malformed YAML
|
||||
" - \"[[slug|{\"Title\": 'Subtitle'}]]\"",
|
||||
"Trashed: true",
|
||||
"\"Trashed at\": 2026-03-11",
|
||||
"---",
|
||||
"",
|
||||
"# Engineering Management",
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "eng-mgmt.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("eng-mgmt.md"), None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when YAML is malformed (fallback parser)"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Topic".to_string()),
|
||||
"isA must be extracted by fallback parser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fm = [
|
||||
"---",
|
||||
"type: Essay",
|
||||
"Notes:",
|
||||
" - \"[[slug|{\"Broken\": 'quotes'}]]\"",
|
||||
"Archived: true",
|
||||
"---",
|
||||
"",
|
||||
"# Archived Essay",
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "archived-essay.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("archived-essay.md"), None).unwrap();
|
||||
assert!(
|
||||
entry.archived,
|
||||
"Archived must be true even when YAML is malformed"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Essay".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_visible_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1211,6 +1417,46 @@ fn test_array_field_does_not_break_type_detection() {
|
||||
assert_eq!(entry.status, Some("Active".to_string()));
|
||||
}
|
||||
|
||||
// ── Folder tree tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_folders_returns_tree() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("projects/laputa")).unwrap();
|
||||
fs::create_dir_all(dir.path().join("areas")).unwrap();
|
||||
|
||||
let folders = scan_vault_folders(dir.path()).unwrap();
|
||||
let names: Vec<&str> = folders.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(names.contains(&"projects"));
|
||||
assert!(names.contains(&"areas"));
|
||||
|
||||
let projects = folders.iter().find(|f| f.name == "projects").unwrap();
|
||||
assert_eq!(projects.children.len(), 1);
|
||||
assert_eq!(projects.children[0].name, "laputa");
|
||||
assert_eq!(projects.children[0].path, "projects/laputa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_folders_excludes_hidden() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join(".git")).unwrap();
|
||||
fs::create_dir_all(dir.path().join(".laputa")).unwrap();
|
||||
fs::create_dir_all(dir.path().join("visible")).unwrap();
|
||||
|
||||
let folders = scan_vault_folders(dir.path()).unwrap();
|
||||
assert_eq!(folders.len(), 1);
|
||||
assert_eq!(folders[0].name, "visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_folders_flat_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "note.md", "# Note\n");
|
||||
|
||||
let folders = scan_vault_folders(dir.path()).unwrap();
|
||||
assert!(folders.is_empty(), "flat vault has no visible folders");
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -144,9 +144,10 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
|
||||
}
|
||||
|
||||
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
|
||||
fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
|
||||
/// `exclude` is the source file being renamed — it should not be treated as a collision.
|
||||
fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::path::PathBuf {
|
||||
let dest = dest_dir.join(filename);
|
||||
if !dest.exists() {
|
||||
if !dest.exists() || dest == exclude {
|
||||
return dest;
|
||||
}
|
||||
let stem = Path::new(filename)
|
||||
@@ -160,7 +161,7 @@ fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
|
||||
let mut counter = 2;
|
||||
loop {
|
||||
let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
|
||||
if !candidate.exists() {
|
||||
if !candidate.exists() || candidate == exclude {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
@@ -223,7 +224,7 @@ pub fn rename_note(
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = unique_dest_path(parent_dir, &expected_filename);
|
||||
let new_file = unique_dest_path(parent_dir, &expected_filename, old_file);
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
fs::write(&new_file, &updated_content)
|
||||
@@ -250,6 +251,89 @@ pub fn rename_note(
|
||||
})
|
||||
}
|
||||
|
||||
/// A detected rename: old path → new path (both relative to vault root).
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DetectedRename {
|
||||
pub old_path: String,
|
||||
pub new_path: String,
|
||||
}
|
||||
|
||||
/// Detect renamed files by comparing working tree against HEAD using git diff.
|
||||
pub fn detect_renames(vault_path: &str) -> Result<Vec<DetectedRename>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(vec![]); // No HEAD yet or other git issue — no renames
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let renames: Vec<DetectedRename> = stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 3 && parts[0].starts_with('R') {
|
||||
let old = parts[1].to_string();
|
||||
let new = parts[2].to_string();
|
||||
if old.ends_with(".md") && new.ends_with(".md") {
|
||||
return Some(DetectedRename {
|
||||
old_path: old,
|
||||
new_path: new,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(renames)
|
||||
}
|
||||
|
||||
/// Update wikilinks across the vault for a list of detected renames.
|
||||
/// Returns the total number of files updated.
|
||||
pub fn update_wikilinks_for_renames(
|
||||
vault_path: &str,
|
||||
renames: &[DetectedRename],
|
||||
) -> Result<usize, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let mut total_updated = 0;
|
||||
|
||||
for rename in renames {
|
||||
let old_stem = rename
|
||||
.old_path
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&rename.old_path);
|
||||
let new_stem = rename
|
||||
.new_path
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&rename.new_path);
|
||||
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(old_stem);
|
||||
let new_filename_stem = new_stem.split('/').next_back().unwrap_or(new_stem);
|
||||
|
||||
// Build title from filename stem (kebab-case → Title Case)
|
||||
let old_title = super::parsing::slug_to_title(old_filename_stem);
|
||||
let new_title = super::parsing::slug_to_title(new_filename_stem);
|
||||
|
||||
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
|
||||
let new_file = vault.join(&rename.new_path);
|
||||
|
||||
let updated = update_wikilinks_in_vault(&WikilinkReplacement {
|
||||
vault_path: vault,
|
||||
old_title: &old_title,
|
||||
new_title: &new_title,
|
||||
old_path_stem: old_filename_stem,
|
||||
exclude_path: &new_file,
|
||||
});
|
||||
total_updated += updated;
|
||||
}
|
||||
|
||||
Ok(total_updated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,7 +14,10 @@ fn extract_trashed_at_string(data: &Option<gray_matter::Pod>) -> Option<String>
|
||||
let gray_matter::Pod::Hash(ref map) = data.as_ref()? else {
|
||||
return None;
|
||||
};
|
||||
let pod = map.get("Trashed at").or_else(|| map.get("trashed_at"))?;
|
||||
let pod = map
|
||||
.get("_trashed_at")
|
||||
.or_else(|| map.get("Trashed at"))
|
||||
.or_else(|| map.get("trashed_at"))?;
|
||||
match pod {
|
||||
gray_matter::Pod::String(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
@@ -74,7 +77,11 @@ pub fn is_file_trashed(path: &Path) -> bool {
|
||||
|
||||
// Check for "Trashed: true"
|
||||
if let Some(gray_matter::Pod::Hash(ref map)) = parsed.data {
|
||||
if let Some(pod) = map.get("Trashed").or_else(|| map.get("trashed")) {
|
||||
if let Some(pod) = map
|
||||
.get("_trashed")
|
||||
.or_else(|| map.get("Trashed"))
|
||||
.or_else(|| map.get("trashed"))
|
||||
{
|
||||
return match pod {
|
||||
gray_matter::Pod::Boolean(b) => *b,
|
||||
gray_matter::Pod::String(s) => {
|
||||
@@ -373,6 +380,28 @@ mod tests {
|
||||
assert!(!is_file_trashed(Path::new("/nonexistent/path.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_underscore_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\n_trashed: true\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_underscore_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_false() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
674
src-tauri/src/vault/views.rs
Normal file
674
src-tauri/src/vault/views.rs
Normal file
@@ -0,0 +1,674 @@
|
||||
use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::VaultEntry;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ViewDefinition {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
pub filters: FilterGroup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FilterGroup {
|
||||
All(Vec<FilterNode>),
|
||||
Any(Vec<FilterNode>),
|
||||
}
|
||||
|
||||
impl Serialize for FilterGroup {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeMap;
|
||||
let mut map = serializer.serialize_map(Some(1))?;
|
||||
match self {
|
||||
FilterGroup::All(nodes) => map.serialize_entry("all", nodes)?,
|
||||
FilterGroup::Any(nodes) => map.serialize_entry("any", nodes)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FilterGroup {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct FilterGroupVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for FilterGroupVisitor {
|
||||
type Value = FilterGroup;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a map with key 'all' or 'any'")
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<FilterGroup, M::Error> {
|
||||
let key: String = map
|
||||
.next_key()?
|
||||
.ok_or_else(|| de::Error::custom("expected 'all' or 'any' key"))?;
|
||||
match key.as_str() {
|
||||
"all" => {
|
||||
let nodes: Vec<FilterNode> = map.next_value()?;
|
||||
Ok(FilterGroup::All(nodes))
|
||||
}
|
||||
"any" => {
|
||||
let nodes: Vec<FilterNode> = map.next_value()?;
|
||||
Ok(FilterGroup::Any(nodes))
|
||||
}
|
||||
other => Err(de::Error::unknown_field(other, &["all", "any"])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(FilterGroupVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FilterNode {
|
||||
Condition(FilterCondition),
|
||||
Group(FilterGroup),
|
||||
}
|
||||
|
||||
impl Serialize for FilterNode {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
FilterNode::Condition(c) => c.serialize(serializer),
|
||||
FilterNode::Group(g) => g.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FilterNode {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
// Deserialize into a generic YAML value, then try group first, then condition
|
||||
let value = serde_yaml::Value::deserialize(deserializer)?;
|
||||
if let serde_yaml::Value::Mapping(ref m) = value {
|
||||
// If the map has an "all" or "any" key, it's a group
|
||||
let all_key = serde_yaml::Value::String("all".to_string());
|
||||
let any_key = serde_yaml::Value::String("any".to_string());
|
||||
if m.contains_key(&all_key) || m.contains_key(&any_key) {
|
||||
let group: FilterGroup =
|
||||
serde_yaml::from_value(value).map_err(de::Error::custom)?;
|
||||
return Ok(FilterNode::Group(group));
|
||||
}
|
||||
}
|
||||
let cond: FilterCondition = serde_yaml::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(FilterNode::Condition(cond))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FilterCondition {
|
||||
pub field: String,
|
||||
pub op: FilterOp,
|
||||
#[serde(default)]
|
||||
pub value: Option<serde_yaml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum FilterOp {
|
||||
#[serde(rename = "equals")]
|
||||
Equals,
|
||||
#[serde(rename = "not_equals")]
|
||||
NotEquals,
|
||||
#[serde(rename = "contains")]
|
||||
Contains,
|
||||
#[serde(rename = "not_contains")]
|
||||
NotContains,
|
||||
#[serde(rename = "any_of")]
|
||||
AnyOf,
|
||||
#[serde(rename = "none_of")]
|
||||
NoneOf,
|
||||
#[serde(rename = "is_empty")]
|
||||
IsEmpty,
|
||||
#[serde(rename = "is_not_empty")]
|
||||
IsNotEmpty,
|
||||
#[serde(rename = "before")]
|
||||
Before,
|
||||
#[serde(rename = "after")]
|
||||
After,
|
||||
}
|
||||
|
||||
/// A view file on disk: filename + parsed definition.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ViewFile {
|
||||
pub filename: String,
|
||||
pub definition: ViewDefinition,
|
||||
}
|
||||
|
||||
/// Scan all `.yml` files from `vault_path/.laputa/views/` and return parsed views.
|
||||
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
|
||||
let views_dir = vault_path.join(".laputa").join("views");
|
||||
if !views_dir.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut views = Vec::new();
|
||||
let entries = match fs::read_dir(&views_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read views directory: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("yml") {
|
||||
continue;
|
||||
}
|
||||
let filename = entry.file_name().to_string_lossy().to_string();
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => match serde_yaml::from_str::<ViewDefinition>(&content) {
|
||||
Ok(definition) => views.push(ViewFile {
|
||||
filename,
|
||||
definition,
|
||||
}),
|
||||
Err(e) => log::warn!("Failed to parse view {}: {}", filename, e),
|
||||
},
|
||||
Err(e) => log::warn!("Failed to read view file {}: {}", filename, e),
|
||||
}
|
||||
}
|
||||
|
||||
views.sort_by(|a, b| a.filename.cmp(&b.filename));
|
||||
views
|
||||
}
|
||||
|
||||
/// Save a view definition as YAML to `vault_path/.laputa/views/{filename}`.
|
||||
pub fn save_view(
|
||||
vault_path: &Path,
|
||||
filename: &str,
|
||||
definition: &ViewDefinition,
|
||||
) -> Result<(), String> {
|
||||
if !filename.ends_with(".yml") {
|
||||
return Err("Filename must end with .yml".to_string());
|
||||
}
|
||||
let views_dir = vault_path.join(".laputa").join("views");
|
||||
fs::create_dir_all(&views_dir)
|
||||
.map_err(|e| format!("Failed to create views directory: {}", e))?;
|
||||
let yaml = serde_yaml::to_string(definition)
|
||||
.map_err(|e| format!("Failed to serialize view: {}", e))?;
|
||||
fs::write(views_dir.join(filename), yaml)
|
||||
.map_err(|e| format!("Failed to write view file: {}", e))
|
||||
}
|
||||
|
||||
/// Delete a view file at `vault_path/.laputa/views/{filename}`.
|
||||
pub fn delete_view(vault_path: &Path, filename: &str) -> Result<(), String> {
|
||||
let path = vault_path.join(".laputa").join("views").join(filename);
|
||||
fs::remove_file(&path).map_err(|e| format!("Failed to delete view: {}", e))
|
||||
}
|
||||
|
||||
/// Evaluate a view definition against vault entries, returning indices of matching entries.
|
||||
pub fn evaluate_view(definition: &ViewDefinition, entries: &[VaultEntry]) -> Vec<usize> {
|
||||
entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| evaluate_group(&definition.filters, entry))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn evaluate_group(group: &FilterGroup, entry: &VaultEntry) -> bool {
|
||||
match group {
|
||||
FilterGroup::All(nodes) => nodes.iter().all(|n| evaluate_node(n, entry)),
|
||||
FilterGroup::Any(nodes) => nodes.iter().any(|n| evaluate_node(n, entry)),
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_node(node: &FilterNode, entry: &VaultEntry) -> bool {
|
||||
match node {
|
||||
FilterNode::Condition(cond) => evaluate_condition(cond, entry),
|
||||
FilterNode::Group(group) => evaluate_group(group, entry),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the stem from a wikilink: `[[target|Alias]]` -> `target`, `[[target]]` -> `target`.
|
||||
fn wikilink_stem(link: &str) -> &str {
|
||||
let s = link
|
||||
.strip_prefix("[[")
|
||||
.unwrap_or(link)
|
||||
.strip_suffix("]]")
|
||||
.unwrap_or(link);
|
||||
match s.split_once('|') {
|
||||
Some((stem, _)) => stem,
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
|
||||
let field = cond.field.as_str();
|
||||
|
||||
// Boolean fields
|
||||
match field {
|
||||
"archived" => return evaluate_bool_field(entry.archived, &cond.op, &cond.value),
|
||||
"trashed" => return evaluate_bool_field(entry.trashed, &cond.op, &cond.value),
|
||||
"favorite" => return evaluate_bool_field(entry.favorite, &cond.op, &cond.value),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// String/option fields
|
||||
let field_value: Option<String> = match field {
|
||||
"type" | "isA" => entry.is_a.clone(),
|
||||
"status" => entry.status.clone(),
|
||||
"title" => Some(entry.title.clone()),
|
||||
_ => {
|
||||
// Check properties first, then relationships
|
||||
if let Some(prop) = entry.properties.get(field) {
|
||||
match prop {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
} else if let Some(rels) = entry.relationships.get(field) {
|
||||
// For relationship fields, handle specially in contains/any_of etc.
|
||||
return evaluate_relationship_op(&cond.op, rels, &cond.value);
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let cond_value = cond.value.as_ref().and_then(yaml_value_to_string);
|
||||
|
||||
match cond.op {
|
||||
FilterOp::Equals => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => f.eq_ignore_ascii_case(v),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
},
|
||||
FilterOp::NotEquals => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => !f.eq_ignore_ascii_case(v),
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
},
|
||||
FilterOp::Contains => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => f.to_lowercase().contains(&v.to_lowercase()),
|
||||
_ => false,
|
||||
},
|
||||
FilterOp::NotContains => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => !f.to_lowercase().contains(&v.to_lowercase()),
|
||||
(None, _) => true,
|
||||
_ => true,
|
||||
},
|
||||
FilterOp::AnyOf => {
|
||||
let values = cond
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default();
|
||||
match &field_value {
|
||||
Some(f) => values.iter().any(|v| f.eq_ignore_ascii_case(v)),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
FilterOp::NoneOf => {
|
||||
let values = cond
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default();
|
||||
match &field_value {
|
||||
Some(f) => !values.iter().any(|v| f.eq_ignore_ascii_case(v)),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
FilterOp::IsEmpty => field_value.as_deref().map_or(true, |s| s.is_empty()),
|
||||
FilterOp::IsNotEmpty => field_value.as_deref().is_some_and(|s| !s.is_empty()),
|
||||
FilterOp::Before => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => f < v,
|
||||
_ => false,
|
||||
},
|
||||
FilterOp::After => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => f > v,
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_bool_field(field_val: bool, op: &FilterOp, value: &Option<serde_yaml::Value>) -> bool {
|
||||
match op {
|
||||
FilterOp::Equals => {
|
||||
let expected = value.as_ref().and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
field_val == expected
|
||||
}
|
||||
FilterOp::NotEquals => {
|
||||
let expected = value.as_ref().and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
field_val != expected
|
||||
}
|
||||
FilterOp::IsEmpty => !field_val,
|
||||
FilterOp::IsNotEmpty => field_val,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_relationship_op(
|
||||
op: &FilterOp,
|
||||
rels: &[String],
|
||||
value: &Option<serde_yaml::Value>,
|
||||
) -> bool {
|
||||
match op {
|
||||
FilterOp::Contains => {
|
||||
let target = value.as_ref().and_then(yaml_value_to_string);
|
||||
match target {
|
||||
Some(t) => {
|
||||
let t_stem = wikilink_stem(&t).to_lowercase();
|
||||
rels.iter()
|
||||
.any(|r| wikilink_stem(r).to_lowercase() == t_stem)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
FilterOp::NotContains => {
|
||||
let target = value.as_ref().and_then(yaml_value_to_string);
|
||||
match target {
|
||||
Some(t) => {
|
||||
let t_stem = wikilink_stem(&t).to_lowercase();
|
||||
!rels
|
||||
.iter()
|
||||
.any(|r| wikilink_stem(r).to_lowercase() == t_stem)
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
FilterOp::AnyOf => {
|
||||
let values = value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default();
|
||||
rels.iter().any(|r| {
|
||||
let r_stem = wikilink_stem(r).to_lowercase();
|
||||
values
|
||||
.iter()
|
||||
.any(|v| wikilink_stem(v).to_lowercase() == r_stem)
|
||||
})
|
||||
}
|
||||
FilterOp::NoneOf => {
|
||||
let values = value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default();
|
||||
!rels.iter().any(|r| {
|
||||
let r_stem = wikilink_stem(r).to_lowercase();
|
||||
values
|
||||
.iter()
|
||||
.any(|v| wikilink_stem(v).to_lowercase() == r_stem)
|
||||
})
|
||||
}
|
||||
FilterOp::IsEmpty => rels.is_empty(),
|
||||
FilterOp::IsNotEmpty => !rels.is_empty(),
|
||||
FilterOp::Equals => {
|
||||
let target = value.as_ref().and_then(yaml_value_to_string);
|
||||
match target {
|
||||
Some(t) => {
|
||||
rels.len() == 1
|
||||
&& wikilink_stem(&rels[0]).to_lowercase()
|
||||
== wikilink_stem(&t).to_lowercase()
|
||||
}
|
||||
None => rels.is_empty(),
|
||||
}
|
||||
}
|
||||
FilterOp::NotEquals => {
|
||||
let target = value.as_ref().and_then(yaml_value_to_string);
|
||||
match target {
|
||||
Some(t) => {
|
||||
rels.len() != 1
|
||||
|| wikilink_stem(&rels[0]).to_lowercase()
|
||||
!= wikilink_stem(&t).to_lowercase()
|
||||
}
|
||||
None => !rels.is_empty(),
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn yaml_value_to_string(v: &serde_yaml::Value) -> Option<String> {
|
||||
match v {
|
||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||
serde_yaml::Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn yaml_value_to_string_vec(v: &serde_yaml::Value) -> Option<Vec<String>> {
|
||||
v.as_sequence()
|
||||
.map(|seq| seq.iter().filter_map(yaml_value_to_string).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_entry(overrides: impl FnOnce(&mut VaultEntry)) -> VaultEntry {
|
||||
let mut entry = VaultEntry::default();
|
||||
overrides(&mut entry);
|
||||
entry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_view() {
|
||||
let yaml = r#"
|
||||
name: Active Projects
|
||||
icon: rocket
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(def.name, "Active Projects");
|
||||
assert_eq!(def.icon.as_deref(), Some("rocket"));
|
||||
match &def.filters {
|
||||
FilterGroup::All(nodes) => {
|
||||
assert_eq!(nodes.len(), 1);
|
||||
match &nodes[0] {
|
||||
FilterNode::Condition(c) => {
|
||||
assert_eq!(c.field, "type");
|
||||
}
|
||||
_ => panic!("Expected condition"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected All group"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_equals() {
|
||||
let yaml = r#"
|
||||
name: Projects
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let matching = make_entry(|e| e.is_a = Some("Project".to_string()));
|
||||
let non_matching = make_entry(|e| e.is_a = Some("Note".to_string()));
|
||||
let entries = vec![matching, non_matching];
|
||||
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_contains_relationship() {
|
||||
let yaml = r#"
|
||||
name: Related to Target
|
||||
filters:
|
||||
all:
|
||||
- field: Related to
|
||||
op: contains
|
||||
value: "[[target]]"
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let mut rels = HashMap::new();
|
||||
rels.insert(
|
||||
"Related to".to_string(),
|
||||
vec!["[[target]]".to_string(), "[[other]]".to_string()],
|
||||
);
|
||||
let matching = make_entry(|e| e.relationships = rels);
|
||||
|
||||
let non_matching = make_entry(|_| {});
|
||||
let entries = vec![matching, non_matching];
|
||||
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_nested_and_or() {
|
||||
let yaml = r#"
|
||||
name: Complex
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- any:
|
||||
- field: status
|
||||
op: equals
|
||||
value: Active
|
||||
- field: status
|
||||
op: equals
|
||||
value: Planning
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let active_project = make_entry(|e| {
|
||||
e.is_a = Some("Project".to_string());
|
||||
e.status = Some("Active".to_string());
|
||||
});
|
||||
let planning_project = make_entry(|e| {
|
||||
e.is_a = Some("Project".to_string());
|
||||
e.status = Some("Planning".to_string());
|
||||
});
|
||||
let done_project = make_entry(|e| {
|
||||
e.is_a = Some("Project".to_string());
|
||||
e.status = Some("Done".to_string());
|
||||
});
|
||||
let active_note = make_entry(|e| {
|
||||
e.is_a = Some("Note".to_string());
|
||||
e.status = Some("Active".to_string());
|
||||
});
|
||||
|
||||
let entries = vec![active_project, planning_project, done_project, active_note];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_is_empty() {
|
||||
let yaml_empty = r#"
|
||||
name: No Status
|
||||
filters:
|
||||
all:
|
||||
- field: status
|
||||
op: is_empty
|
||||
"#;
|
||||
let yaml_not_empty = r#"
|
||||
name: Has Status
|
||||
filters:
|
||||
all:
|
||||
- field: status
|
||||
op: is_not_empty
|
||||
"#;
|
||||
let def_empty: ViewDefinition = serde_yaml::from_str(yaml_empty).unwrap();
|
||||
let def_not_empty: ViewDefinition = serde_yaml::from_str(yaml_not_empty).unwrap();
|
||||
|
||||
let with_status = make_entry(|e| e.status = Some("Active".to_string()));
|
||||
let without_status = make_entry(|_| {});
|
||||
let entries = vec![with_status, without_status];
|
||||
|
||||
assert_eq!(evaluate_view(&def_empty, &entries), vec![1]);
|
||||
assert_eq!(evaluate_view(&def_not_empty, &entries), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_views_reads_yml_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let views_dir = dir.path().join(".laputa").join("views");
|
||||
fs::create_dir_all(&views_dir).unwrap();
|
||||
|
||||
let yaml_a = "name: Alpha\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
|
||||
let yaml_b = "name: Beta\nfilters:\n any:\n - field: status\n op: equals\n value: Active\n";
|
||||
fs::write(views_dir.join("a-view.yml"), yaml_a).unwrap();
|
||||
fs::write(views_dir.join("b-view.yml"), yaml_b).unwrap();
|
||||
// Non-yml file should be ignored
|
||||
fs::write(views_dir.join("readme.txt"), "ignore me").unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 2);
|
||||
assert_eq!(views[0].filename, "a-view.yml");
|
||||
assert_eq!(views[0].definition.name, "Alpha");
|
||||
assert_eq!(views[1].filename, "b-view.yml");
|
||||
assert_eq!(views[1].definition.name, "Beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_read_view() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let def = ViewDefinition {
|
||||
name: "Test View".to_string(),
|
||||
icon: Some("star".to_string()),
|
||||
color: None,
|
||||
sort: Some("modified:desc".to_string()),
|
||||
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
|
||||
field: "type".to_string(),
|
||||
op: FilterOp::Equals,
|
||||
value: Some(serde_yaml::Value::String("Project".to_string())),
|
||||
})]),
|
||||
};
|
||||
|
||||
save_view(dir.path(), "test.yml", &def).unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Test View");
|
||||
assert_eq!(views[0].definition.icon.as_deref(), Some("star"));
|
||||
|
||||
delete_view(dir.path(), "test.yml").unwrap();
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wikilink_stem_matching() {
|
||||
let yaml = r#"
|
||||
name: Linked
|
||||
filters:
|
||||
all:
|
||||
- field: Topics
|
||||
op: contains
|
||||
value: "[[target]]"
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
// Entry with aliased wikilink
|
||||
let mut rels = HashMap::new();
|
||||
rels.insert("Topics".to_string(), vec!["[[target|Alias]]".to_string()]);
|
||||
let matching = make_entry(|e| e.relationships = rels);
|
||||
|
||||
// Entry with different target
|
||||
let mut rels2 = HashMap::new();
|
||||
rels2.insert("Topics".to_string(), vec!["[[other|Alias]]".to_string()]);
|
||||
let non_matching = make_entry(|e| e.relationships = rels2);
|
||||
|
||||
let entries = vec![matching, non_matching];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,14 @@
|
||||
"title": "Laputa",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 1200,
|
||||
"minHeight": 400,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"backgroundColor": "#F7F6F3"
|
||||
"backgroundColor": "#F7F6F3",
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
14
src/App.css
14
src/App.css
@@ -2,8 +2,8 @@
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 0 1px var(--border-primary);
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
}
|
||||
|
||||
.app__sidebar {
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 1;
|
||||
min-width: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -26,7 +27,8 @@
|
||||
}
|
||||
|
||||
.app__note-list {
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 10;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -37,11 +39,13 @@
|
||||
|
||||
.app__editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-width: 400px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container-type: inline-size;
|
||||
container-name: editor;
|
||||
}
|
||||
|
||||
.app__editor > * {
|
||||
|
||||
@@ -63,6 +63,8 @@ const mockAllContent: Record<string, string> = {
|
||||
|
||||
const mockCommandResults: Record<string, unknown> = {
|
||||
list_vault: mockEntries,
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
|
||||
203
src/App.tsx
203
src/App.tsx
@@ -4,6 +4,7 @@ import { NoteList } from './components/NoteList'
|
||||
import { Editor } from './components/Editor'
|
||||
import { ResizeHandle } from './components/ResizeHandle'
|
||||
import { CreateTypeDialog } from './components/CreateTypeDialog'
|
||||
import { CreateViewDialog } from './components/CreateViewDialog'
|
||||
import { QuickOpenPalette } from './components/QuickOpenPalette'
|
||||
import { CommandPalette } from './components/CommandPalette'
|
||||
import { SearchPanel } from './components/SearchPanel'
|
||||
@@ -25,6 +26,7 @@ import { useViewMode } from './hooks/useViewMode'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { isEmoji } from './utils/emoji'
|
||||
import { generateCommitMessage } from './utils/commitMessage'
|
||||
import { useDialogs } from './hooks/useDialogs'
|
||||
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useGitHistory } from './hooks/useGitHistory'
|
||||
@@ -46,14 +48,15 @@ import { useVaultBridge } from './hooks/useVaultBridge'
|
||||
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
||||
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { FlatVaultMigrationBanner } from './components/FlatVaultMigrationBanner'
|
||||
import { useFlatVaultMigration } from './hooks/useFlatVaultMigration'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import type { SidebarSelection, InboxPeriod } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
|
||||
import { GitRequiredModal } from './components/GitRequiredModal'
|
||||
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
|
||||
import './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -69,6 +72,7 @@ const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function App() {
|
||||
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
|
||||
const [inboxPeriod, setInboxPeriod] = useState<InboxPeriod>('month')
|
||||
@@ -76,7 +80,7 @@ function App() {
|
||||
setSelection(sel)
|
||||
setNoteListFilter('open')
|
||||
}, [])
|
||||
const layout = useLayoutPanels()
|
||||
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
|
||||
@@ -91,12 +95,29 @@ function App() {
|
||||
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
|
||||
|
||||
// When onboarding resolves to a different vault path, update the switcher
|
||||
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
|
||||
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
|
||||
// Git repo check: 'checking' | 'required' | 'ready'
|
||||
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
|
||||
useEffect(() => {
|
||||
if (!resolvedPath) return
|
||||
setGitRepoState('checking')
|
||||
const check = isTauri()
|
||||
? invoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
|
||||
: Promise.resolve(true) // browser mock: assume git
|
||||
check
|
||||
.then(isGit => setGitRepoState(isGit ? 'ready' : 'required'))
|
||||
.catch(() => setGitRepoState('ready')) // fail open
|
||||
}, [resolvedPath])
|
||||
|
||||
const handleInitGitRepo = useCallback(async () => {
|
||||
if (isTauri()) await invoke('init_git_repo', { vaultPath: resolvedPath })
|
||||
setGitRepoState('ready')
|
||||
}, [resolvedPath])
|
||||
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
useVaultConfig(resolvedPath)
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
useTelemetry(settings, settingsLoaded)
|
||||
const flatVaultMigration = useFlatVaultMigration(resolvedPath, vault.entries.length > 0, vault.reloadVault)
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
@@ -110,6 +131,33 @@ function App() {
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
|
||||
// Detect external file renames on window focus
|
||||
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
|
||||
useEffect(() => {
|
||||
if (!isTauri() || !resolvedPath) return
|
||||
const handleFocus = () => {
|
||||
invoke<DetectedRename[]>('detect_renames', { vaultPath: resolvedPath })
|
||||
.then(renames => { if (renames.length > 0) setDetectedRenames(renames) })
|
||||
.catch(() => {}) // ignore errors (e.g., no git)
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
}, [resolvedPath])
|
||||
|
||||
const handleUpdateWikilinks = useCallback(async () => {
|
||||
if (!isTauri()) return
|
||||
try {
|
||||
const count = await invoke<number>('update_wikilinks_for_renames', { vaultPath: resolvedPath, renames: detectedRenames })
|
||||
setDetectedRenames([])
|
||||
vault.reloadVault()
|
||||
setToastMessage(`Updated wikilinks in ${count} file${count !== 1 ? 's' : ''}`)
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to update wikilinks: ${err}`)
|
||||
}
|
||||
}, [resolvedPath, detectedRenames, vault, setToastMessage])
|
||||
|
||||
const handleDismissRenames = useCallback(() => setDetectedRenames([]), [])
|
||||
|
||||
const conflictResolver = useConflictResolver({
|
||||
vaultPath: resolvedPath,
|
||||
onResolved: () => {
|
||||
@@ -124,6 +172,28 @@ function App() {
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => appSave.contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
|
||||
|
||||
// Note window: auto-open the note from URL params once vault entries load
|
||||
const noteWindowOpenedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!noteWindowParams || noteWindowOpenedRef.current || vault.entries.length === 0) return
|
||||
const entry = vault.entries.find(e => e.path === noteWindowParams.notePath)
|
||||
if (entry) {
|
||||
noteWindowOpenedRef.current = true
|
||||
notes.handleSelectNote(entry)
|
||||
}
|
||||
}, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- run when entries load, params are stable
|
||||
|
||||
// Note window: update window title when active note changes
|
||||
useEffect(() => {
|
||||
if (!noteWindowParams) return
|
||||
const activeEntry = notes.tabs.find(t => t.entry.path === notes.activeTabPath)?.entry
|
||||
const title = activeEntry?.title ?? noteWindowParams.noteTitle
|
||||
if (!isTauri()) { document.title = title; return }
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
||||
getCurrentWindow().setTitle(title)
|
||||
}).catch(() => {})
|
||||
}, [noteWindowParams, notes.tabs, notes.activeTabPath])
|
||||
|
||||
// Keep note entry in sync with vault entries so banners (trash/archive)
|
||||
// and read-only state react immediately without reopening the note.
|
||||
useEffect(() => {
|
||||
@@ -185,6 +255,12 @@ function App() {
|
||||
onVaultChanged: () => { vault.reloadVault() },
|
||||
})
|
||||
|
||||
const handleInitializeProperties = useCallback(async (path: string) => {
|
||||
const filename = path.split('/').pop()?.replace(/\.md$/, '') ?? 'Untitled'
|
||||
await notes.handleUpdateFrontmatter(path, 'type', 'Note', { silent: true })
|
||||
await notes.handleUpdateFrontmatter(path, 'title', filename)
|
||||
}, [notes])
|
||||
|
||||
const handleSetNoteIcon = useCallback(async (path: string, emoji: string) => {
|
||||
await notes.handleUpdateFrontmatter(path, 'icon', emoji)
|
||||
}, [notes])
|
||||
@@ -197,6 +273,20 @@ function App() {
|
||||
window.dispatchEvent(new CustomEvent('laputa:open-icon-picker'))
|
||||
}, [])
|
||||
|
||||
const handleCreateFolder = useCallback(async (name: string) => {
|
||||
try {
|
||||
if (isTauri()) {
|
||||
await invoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
|
||||
} else {
|
||||
await mockInvoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
|
||||
}
|
||||
await vault.reloadFolders()
|
||||
setToastMessage(`Created folder "${name}"`)
|
||||
} catch (e) {
|
||||
setToastMessage(`Failed to create folder: ${e}`)
|
||||
}
|
||||
}, [resolvedPath, vault, setToastMessage])
|
||||
|
||||
const handleRemoveNoteIconCommand = useCallback(() => {
|
||||
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
|
||||
}, [notes.activeTabPath, handleRemoveNoteIcon])
|
||||
@@ -211,6 +301,7 @@ function App() {
|
||||
}, [resolvedPath])
|
||||
|
||||
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
|
||||
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
|
||||
|
||||
const entryActions = useEntryActions({
|
||||
entries: vault.entries, updateEntry: vault.updateEntry,
|
||||
@@ -236,6 +327,51 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
|
||||
const handleCreateView = useCallback(async (definition: import('./types').ViewDefinition) => {
|
||||
const filename = definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
|
||||
await vault.reloadViews()
|
||||
setToastMessage(`View "${definition.name}" created`)
|
||||
handleSetSelection({ kind: 'view', filename })
|
||||
}, [resolvedPath, vault, handleSetSelection])
|
||||
|
||||
const handleDeleteView = useCallback(async (filename: string) => {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
await vault.reloadViews()
|
||||
if (selection.kind === 'view' && selection.filename === filename) {
|
||||
handleSetSelection({ kind: 'filter', filter: 'all' })
|
||||
}
|
||||
setToastMessage('View deleted')
|
||||
}, [resolvedPath, vault, selection, handleSetSelection])
|
||||
|
||||
const availableFields = useMemo(() => {
|
||||
const builtIn = ['type', 'status', 'title', 'favorite']
|
||||
if (!vault.entries?.length) return builtIn
|
||||
const customFields = new Set<string>()
|
||||
for (const e of vault.entries) {
|
||||
if (e.properties) {
|
||||
for (const key of Object.keys(e.properties)) customFields.add(key)
|
||||
}
|
||||
if (e.relationships) {
|
||||
for (const key of Object.keys(e.relationships)) customFields.add(key)
|
||||
}
|
||||
}
|
||||
return [...builtIn, ...Array.from(customFields).sort()]
|
||||
}, [vault.entries])
|
||||
|
||||
const valueSuggestionsForField = useCallback((field: string): string[] => {
|
||||
if (!vault.entries?.length) return []
|
||||
const values = new Set<string>()
|
||||
for (const e of vault.entries) {
|
||||
if (field === 'type' && e.isA) values.add(e.isA)
|
||||
else if (field === 'status' && e.status) values.add(e.status)
|
||||
else if (e.properties?.[field] != null) values.add(String(e.properties[field]))
|
||||
}
|
||||
return Array.from(values).sort()
|
||||
}, [vault.entries])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
@@ -243,7 +379,7 @@ function App() {
|
||||
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
|
||||
const diffToggleRef = useRef<() => void>(() => {})
|
||||
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined)
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
|
||||
@@ -330,6 +466,7 @@ function App() {
|
||||
noteListFilter,
|
||||
onSetNoteListFilter: setNoteListFilter,
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
onToggleFavorite: entryActions.handleToggleFavorite,
|
||||
})
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
@@ -338,7 +475,7 @@ function App() {
|
||||
|
||||
const aiNoteList = useMemo<NoteListItem[]>(() => {
|
||||
const isInbox = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection)
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection, undefined, vault.views)
|
||||
return filtered.map(e => ({
|
||||
path: e.path, title: e.title, type: e.isA ?? 'Note',
|
||||
}))
|
||||
@@ -350,18 +487,35 @@ function App() {
|
||||
return { type: null, query: '' }
|
||||
}, [selection])
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist
|
||||
if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') {
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing')) {
|
||||
return <WelcomeView onboarding={onboarding} />
|
||||
}
|
||||
|
||||
// Show loading spinner while checking vault
|
||||
if (onboarding.state.status === 'loading') {
|
||||
// Show loading spinner while checking vault (skip for note windows)
|
||||
if (!noteWindowParams && onboarding.state.status === 'loading') {
|
||||
return <LoadingView />
|
||||
}
|
||||
|
||||
// Show telemetry consent dialog on first launch (or first upgrade with telemetry)
|
||||
if (settingsLoaded && settings.telemetry_consent === null) {
|
||||
// Show git-required modal when vault has no git repo (skip for note windows)
|
||||
if (!noteWindowParams && gitRepoState === 'required') {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<GitRequiredModal
|
||||
onCreateRepo={handleInitGitRepo}
|
||||
onChooseVault={vaultSwitcher.handleOpenLocalFolder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading spinner while checking git status
|
||||
if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') {
|
||||
return <LoadingView />
|
||||
}
|
||||
|
||||
// Show telemetry consent dialog on first launch (skip for note windows)
|
||||
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
|
||||
return (
|
||||
<TelemetryConsentDialog
|
||||
onAccept={() => {
|
||||
@@ -381,7 +535,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} inboxCount={inboxCount} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onDeleteView={handleDeleteView} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -392,7 +546,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} views={vault.views} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -419,11 +573,13 @@ function App() {
|
||||
onDeleteProperty={notes.handleDeleteProperty}
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
|
||||
onInitializeProperties={handleInitializeProperties}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
vaultPath={resolvedPath}
|
||||
noteList={aiNoteList}
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onToggleFavorite={entryActions.handleToggleFavorite}
|
||||
onTrashNote={entryActions.handleTrashNote}
|
||||
onRestoreNote={entryActions.handleRestoreNote}
|
||||
onDeleteNote={deleteActions.handleDeleteNote}
|
||||
@@ -450,25 +606,16 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{flatVaultMigration.needsMigration && (
|
||||
<FlatVaultMigrationBanner
|
||||
strayFileCount={flatVaultMigration.strayFiles.length}
|
||||
isMigrating={flatVaultMigration.isMigrating}
|
||||
onMigrate={async () => {
|
||||
const count = await flatVaultMigration.migrate()
|
||||
setToastMessage(`Migrated ${count} file${count !== 1 ? 's' : ''} to vault root`)
|
||||
}}
|
||||
onDismiss={flatVaultMigration.dismiss}
|
||||
/>
|
||||
)}
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
fileStates={conflictResolver.fileStates}
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Editor } from './components/Editor'
|
||||
import { Toast } from './components/Toast'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import { getNoteWindowParams } from './utils/windowMode'
|
||||
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
|
||||
import { useLayoutPanels } from './hooks/useLayoutPanels'
|
||||
import type { VaultEntry } from './types'
|
||||
import './App.css'
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
content: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal app shell for secondary "note windows" opened via "Open in New Window".
|
||||
* Shows only the editor — no sidebar, no note list.
|
||||
*/
|
||||
export default function NoteWindow() {
|
||||
const params = getNoteWindowParams()
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const activeTabPath = tabs[0]?.entry.path ?? null
|
||||
|
||||
const layout = useLayoutPanels()
|
||||
|
||||
// Load vault entries + note content on mount
|
||||
useEffect(() => {
|
||||
if (!params) return
|
||||
const { vaultPath, notePath } = params
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
const vaultEntries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
|
||||
if (cancelled) return
|
||||
setEntries(vaultEntries)
|
||||
const entry = vaultEntries.find(e => e.path === notePath)
|
||||
if (!entry) return
|
||||
const content = await tauriCall<string>('get_note_content', { path: notePath })
|
||||
if (cancelled) return
|
||||
setTabs([{ entry, content }])
|
||||
}
|
||||
|
||||
load().catch(err => console.error('NoteWindow load failed:', err))
|
||||
return () => { cancelled = true }
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount with captured params
|
||||
|
||||
const vaultPath = params?.vaultPath ?? ''
|
||||
|
||||
// Update window title when note title changes
|
||||
useEffect(() => {
|
||||
const title = tabs[0]?.entry.title
|
||||
if (!title) return
|
||||
if (!isTauri()) { document.title = title; return }
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
||||
getCurrentWindow().setTitle(title)
|
||||
}).catch(() => {})
|
||||
}, [tabs])
|
||||
|
||||
// Auto-save
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
|
||||
setEntries(prev => prev.map(e => e.path === path ? { ...e, ...patch } : e))
|
||||
setTabs(prev => prev.map(t => t.entry.path === path ? { ...t, entry: { ...t.entry, ...patch } } : t))
|
||||
}, [])
|
||||
|
||||
const onAfterSave = useCallback(() => {}, [])
|
||||
const onNotePersisted = useCallback(() => {}, [])
|
||||
|
||||
const { handleSave, handleContentChange } = useEditorSaveWithLinks({
|
||||
updateEntry,
|
||||
setTabs,
|
||||
setToastMessage,
|
||||
onAfterSave,
|
||||
onNotePersisted,
|
||||
})
|
||||
|
||||
// Wikilink navigation — in a note window, open wikilinks in the same window
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
const targetLower = target.toLowerCase()
|
||||
const entry = entries.find(e =>
|
||||
e.title.toLowerCase() === targetLower ||
|
||||
e.aliases.some(a => a.toLowerCase() === targetLower)
|
||||
)
|
||||
if (!entry) return
|
||||
tauriCall<string>('get_note_content', { path: entry.path }).then(content => {
|
||||
setTabs([{ entry, content }])
|
||||
}).catch(() => {})
|
||||
}, [entries])
|
||||
|
||||
// Stub for close tab — in a note window, close the window
|
||||
const handleCloseTab = useCallback(() => {
|
||||
if (!isTauri()) return
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
||||
getCurrentWindow().close()
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Keyboard: Cmd+S to save, Cmd+W to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'w') {
|
||||
e.preventDefault()
|
||||
handleCloseTab()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleSave, handleCloseTab])
|
||||
|
||||
const activeTab = tabs[0] ?? null
|
||||
const gitHistory = useMemo(() => [], [])
|
||||
|
||||
if (!params) {
|
||||
return <div className="app-shell"><p>Invalid note window parameters</p></div>
|
||||
}
|
||||
|
||||
if (!activeTab) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
|
||||
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app">
|
||||
<div className="app__editor">
|
||||
<Editor
|
||||
tabs={tabs}
|
||||
activeTabPath={activeTabPath}
|
||||
entries={entries}
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
inspectorCollapsed={layout.inspectorCollapsed}
|
||||
onToggleInspector={() => layout.setInspectorCollapsed(c => !c)}
|
||||
inspectorWidth={layout.inspectorWidth}
|
||||
onInspectorResize={layout.handleInspectorResize}
|
||||
inspectorEntry={activeTab.entry}
|
||||
inspectorContent={activeTab.content}
|
||||
gitHistory={gitHistory}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () =>
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border"
|
||||
style={{ height: 45, padding: '0 12px', gap: 8 }}
|
||||
style={{ height: 52, padding: '0 12px', gap: 8 }}
|
||||
>
|
||||
<Robot size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
|
||||
@@ -41,7 +41,6 @@ const trashedEntry: VaultEntry = {
|
||||
|
||||
const defaultProps = {
|
||||
wordCount: 100,
|
||||
noteStatus: 'clean' as const,
|
||||
showDiffToggle: false,
|
||||
diffMode: false,
|
||||
diffLoading: false,
|
||||
@@ -84,18 +83,6 @@ describe('BreadcrumbBar — trash/restore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — pending save indicator', () => {
|
||||
it('shows "Saving…" text when noteStatus is pendingSave', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteStatus={'pendingSave'} />)
|
||||
expect(screen.getByText('Saving…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Saving…" text for clean status', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteStatus={'clean'} />)
|
||||
expect(screen.queryByText('Saving…')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
it('shows archive button for non-archived note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
@@ -124,6 +111,50 @@ describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => {
|
||||
it('always renders title elements in the DOM', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
expect(screen.getByText('Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('›')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows emoji icon when entry has an emoji icon', () => {
|
||||
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
|
||||
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
|
||||
expect(screen.getByText('🚀')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show icon when entry has a non-emoji icon', () => {
|
||||
const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' }
|
||||
render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} />)
|
||||
expect(screen.queryByText('cooking-pot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to "Note" when isA is null', () => {
|
||||
const entryNoType = { ...baseEntry, isA: null }
|
||||
render(<BreadcrumbBar entry={entryNoType} {...defaultProps} />)
|
||||
expect(screen.getByText('Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shadow is controlled by data-title-hidden attribute via CSS', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
const bar = container.querySelector('.breadcrumb-bar')!
|
||||
expect(bar).not.toHaveAttribute('data-title-hidden')
|
||||
bar.setAttribute('data-title-hidden', '')
|
||||
expect(bar).toHaveAttribute('data-title-hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — action buttons always right-aligned', () => {
|
||||
it('actions container has ml-auto so buttons are always right-aligned', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
const actions = container.querySelector('.breadcrumb-bar__actions')
|
||||
expect(actions).toBeInTheDocument()
|
||||
expect(actions).toHaveClass('ml-auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { memo } from 'react'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
GitBranch,
|
||||
@@ -14,12 +13,12 @@ import {
|
||||
ArrowCounterClockwise,
|
||||
Archive,
|
||||
ArrowUUpLeft,
|
||||
Star,
|
||||
} from '@phosphor-icons/react'
|
||||
|
||||
interface BreadcrumbBarProps {
|
||||
entry: VaultEntry
|
||||
wordCount: number
|
||||
noteStatus: NoteStatus
|
||||
showDiffToggle: boolean
|
||||
diffMode: boolean
|
||||
diffLoading: boolean
|
||||
@@ -30,10 +29,13 @@ interface BreadcrumbBarProps {
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed?: boolean
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: () => void
|
||||
onTrash?: () => void
|
||||
onRestore?: () => void
|
||||
onArchive?: () => void
|
||||
onUnarchive?: () => void
|
||||
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
|
||||
barRef?: React.Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
@@ -56,10 +58,20 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
rawMode, onToggleRaw,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onTrash, onRestore, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount' | 'noteStatus'>) {
|
||||
onToggleFavorite, onTrash, onRestore, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
|
||||
return (
|
||||
<div className="flex items-center" style={{ gap: 12 }}>
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
entry.favorite ? "text-yellow-500" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleFavorite}
|
||||
title={entry.favorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star size={16} weight={entry.favorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
title="Search in file"
|
||||
@@ -146,7 +158,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onToggleInspector}
|
||||
title="Open Properties"
|
||||
title="Properties (⌘⇧I)"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
@@ -163,51 +175,37 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
|
||||
const typeLabel = entry.isA ?? 'Note'
|
||||
const icon = entry.icon
|
||||
const emojiIcon = icon && /^\p{Emoji}/u.test(icon) ? icon : null
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">{typeLabel}</span>
|
||||
<span className="shrink-0 text-border">›</span>
|
||||
{emojiIcon && <span className="shrink-0">{emojiIcon}</span>}
|
||||
<span className="truncate font-medium text-foreground">{entry.title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry, wordCount, noteStatus, ...actionProps
|
||||
entry, barRef, ...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
data-tauri-drag-region
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
className="breadcrumb-bar flex shrink-0 items-center"
|
||||
style={{
|
||||
height: 45,
|
||||
height: 52,
|
||||
background: 'var(--background)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
padding: '6px 16px',
|
||||
}}
|
||||
>
|
||||
{/* Left: breadcrumb */}
|
||||
<div className="flex items-center gap-1 min-w-0 whitespace-nowrap" style={{ fontSize: 12 }}>
|
||||
<span className="shrink-0 text-muted-foreground">{entry.isA || 'Note'}</span>
|
||||
<span className="shrink-0 text-muted-foreground" style={{ margin: '0 2px' }}>›</span>
|
||||
<span className="truncate font-medium text-foreground" style={{ maxWidth: '40vw' }}>
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="shrink-0 text-muted-foreground">{wordCount.toLocaleString()} words</span>
|
||||
{noteStatus === 'pendingSave' && (
|
||||
<>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="font-semibold tab-status-pulse" style={{ color: 'var(--accent-green)' }}>Saving…</span>
|
||||
</>
|
||||
)}
|
||||
{noteStatus === 'new' && (
|
||||
<>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--accent-green)' }}>N</span>
|
||||
</>
|
||||
)}
|
||||
{noteStatus === 'modified' && (
|
||||
<>
|
||||
<span className="text-muted-foreground" style={{ margin: '0 4px' }}>·</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--accent-yellow)' }}>M</span>
|
||||
</>
|
||||
)}
|
||||
<div className="breadcrumb-bar__title flex-1 min-w-0">
|
||||
<BreadcrumbTitle entry={entry} />
|
||||
</div>
|
||||
|
||||
{/* Right: action icons */}
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -99,10 +99,10 @@ export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCa
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<span className="inline-flex h-6 min-w-0 items-center gap-1.5">
|
||||
{showSwatch && <ColorSwatch color={value} onChange={handlePickerChange} />}
|
||||
<span
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
>
|
||||
|
||||
@@ -78,4 +78,30 @@ describe('CommitDialog', () => {
|
||||
const { container } = render(<CommitDialog open={false} modifiedCount={3} onCommit={onCommit} onClose={onClose} />)
|
||||
expect(container.querySelector('textarea')).toBeNull()
|
||||
})
|
||||
|
||||
it('pre-populates message with suggestedMessage', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha, beta" onCommit={onCommit} onClose={onClose} />)
|
||||
const textarea = screen.getByPlaceholderText('Commit message...')
|
||||
expect(textarea).toHaveValue('Update alpha, beta')
|
||||
})
|
||||
|
||||
it('enables Commit button when suggestedMessage is provided', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
|
||||
expect(getCommitButton()).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('submits suggestedMessage on Cmd+Enter without user edits', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
|
||||
const textarea = screen.getByPlaceholderText('Commit message...')
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', metaKey: true })
|
||||
expect(onCommit).toHaveBeenCalledWith('Update alpha')
|
||||
})
|
||||
|
||||
it('allows user to edit the suggested message', () => {
|
||||
render(<CommitDialog open={true} modifiedCount={3} suggestedMessage="Update alpha" onCommit={onCommit} onClose={onClose} />)
|
||||
const textarea = screen.getByPlaceholderText('Commit message...')
|
||||
fireEvent.change(textarea, { target: { value: 'fix: corrected typo in alpha' } })
|
||||
fireEvent.click(getCommitButton())
|
||||
expect(onCommit).toHaveBeenCalledWith('fix: corrected typo in alpha')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,20 +6,21 @@ import { Badge } from '@/components/ui/badge'
|
||||
interface CommitDialogProps {
|
||||
open: boolean
|
||||
modifiedCount: number
|
||||
suggestedMessage?: string
|
||||
onCommit: (message: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CommitDialog({ open, modifiedCount, onCommit, onClose }: CommitDialogProps) {
|
||||
export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit, onClose }: CommitDialogProps) {
|
||||
const [message, setMessage] = useState('')
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMessage('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setMessage(suggestedMessage ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open])
|
||||
}, [open]) // eslint-disable-line react-hooks/exhaustive-deps -- only reset when dialog opens
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = message.trim()
|
||||
|
||||
110
src/components/CreateViewDialog.tsx
Normal file
110
src/components/CreateViewDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import type { FilterGroup, ViewDefinition } from '../types'
|
||||
|
||||
interface CreateViewDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreate: (definition: ViewDefinition) => void
|
||||
availableFields: string[]
|
||||
/** Returns known values for a given field (for autocomplete). */
|
||||
valueSuggestions?: (field: string) => string[]
|
||||
}
|
||||
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions }: CreateViewDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [filters, setFilters] = useState<FilterGroup>({
|
||||
all: [{ field: 'type', op: 'equals', value: '' }],
|
||||
})
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setShowEmojiPicker(false) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setFilters({ all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open, availableFields])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const definition: ViewDefinition = {
|
||||
name: trimmed,
|
||||
icon: icon || null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters,
|
||||
}
|
||||
onCreate(definition)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSelectEmoji = useCallback((emoji: string) => {
|
||||
setIcon(emoji)
|
||||
setShowEmojiPicker(false)
|
||||
}, [])
|
||||
|
||||
const handleCloseEmojiPicker = useCallback(() => {
|
||||
setShowEmojiPicker(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create View</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-16 space-y-1.5 relative">
|
||||
<label className="text-xs font-medium text-muted-foreground">Icon</label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full items-center justify-center rounded-md border border-input bg-background text-xl cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
title="Pick icon"
|
||||
>
|
||||
{icon || <span className="text-sm text-muted-foreground">📋</span>}
|
||||
</button>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker onSelect={handleSelectEmoji} onClose={handleCloseEmojiPicker} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="e.g. Active Projects, Reading List..."
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Filters</label>
|
||||
<FilterBuilder
|
||||
group={filters}
|
||||
onChange={setFilters}
|
||||
availableFields={availableFields}
|
||||
valueSuggestions={valueSuggestions}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>Create</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/prop grid min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<div className="group/prop grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary" tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
|
||||
<span className="flex min-w-0 items-center gap-1 text-[12px] text-muted-foreground">
|
||||
<span className="truncate">{toSentenceCase(propKey)}</span>
|
||||
{onDelete && (
|
||||
|
||||
@@ -64,9 +64,9 @@ export function UrlValue({
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="group/url inline-flex min-w-0 items-center gap-1">
|
||||
<span className="group/url flex min-w-0 max-w-full items-center gap-1">
|
||||
<span
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
|
||||
className="inline-flex h-6 min-w-0 cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
|
||||
onClick={handleOpen}
|
||||
title={value}
|
||||
data-testid="url-link"
|
||||
@@ -125,7 +125,7 @@ export function EditableValue({
|
||||
|
||||
return (
|
||||
<span
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
>
|
||||
@@ -212,11 +212,11 @@ export function TagPillList({
|
||||
) : (
|
||||
<span
|
||||
key={idx}
|
||||
className="group/pill relative inline-flex cursor-pointer items-center rounded-md transition-colors"
|
||||
className="group/pill relative inline-flex h-6 cursor-pointer items-center rounded-md transition-colors"
|
||||
style={{
|
||||
...getTagStyle(item),
|
||||
backgroundColor: getTagStyle(item).bg,
|
||||
padding: '2px 8px',
|
||||
padding: '0 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
@@ -255,8 +255,8 @@ export function TagPillList({
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-md border-none bg-muted px-2 text-[12px] font-medium leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
style={{ padding: '2px 8px' }}
|
||||
className="inline-flex h-6 items-center justify-center rounded-md border-none bg-muted px-2 text-[12px] font-medium leading-none text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
style={{ padding: '0 8px' }}
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
title={`Add ${label.toLowerCase()}`}
|
||||
>
|
||||
|
||||
@@ -23,6 +23,24 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Breadcrumb bar: title + shadow toggled via data attribute (no React re-render) */
|
||||
.breadcrumb-bar {
|
||||
transition: box-shadow 0.2s ease;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.breadcrumb-bar[data-title-hidden] {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.breadcrumb-bar__title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.breadcrumb-bar[data-title-hidden] .breadcrumb-bar__title {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Scroll area wrapping title + editor — single scroll context for alignment */
|
||||
.editor-scroll-area {
|
||||
flex: 1;
|
||||
@@ -179,16 +197,35 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-section__add-icon {
|
||||
/* "Add icon" button above the title when no emoji — Notion-style */
|
||||
padding-top: 24px;
|
||||
margin-left: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.title-section__row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding-top: 8px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* No emoji: title aligns flush left (no indent for icon area) */
|
||||
.title-section__row--no-icon {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* When emoji is present, restore top padding to the row itself */
|
||||
.title-section__row:has(.note-icon-button--active) {
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.title-section__separator {
|
||||
border-bottom: 1px solid var(--border-primary, rgba(0, 0, 0, 0.08));
|
||||
margin-top: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* --- Note Icon Area --- */
|
||||
@@ -227,7 +264,6 @@
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.title-section:hover .note-icon-button--add,
|
||||
@@ -323,3 +359,13 @@
|
||||
.editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Reduce padding at narrow editor widths so content isn't cramped */
|
||||
@container editor (max-width: 600px) {
|
||||
.editor__blocknote-container .bn-editor {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.title-section {
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,10 +124,11 @@ describe('Editor', () => {
|
||||
activeTabPath={mockEntry.path}
|
||||
/>
|
||||
)
|
||||
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
|
||||
// Tab bar renders when a tab is active
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders breadcrumb bar with note info', () => {
|
||||
it('renders breadcrumb bar with action buttons', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
@@ -135,10 +136,8 @@ describe('Editor', () => {
|
||||
activeTabPath={mockEntry.path}
|
||||
/>
|
||||
)
|
||||
// Breadcrumb shows type and title
|
||||
expect(screen.getByText('Project')).toBeInTheDocument()
|
||||
// Word count shown
|
||||
expect(screen.getByText(/words/)).toBeInTheDocument()
|
||||
// Breadcrumb bar shows action buttons (e.g. search, archive)
|
||||
expect(screen.getByTitle('Search in file')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows BlockNote editor when a tab is active', () => {
|
||||
@@ -152,7 +151,7 @@ describe('Editor', () => {
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows modified indicator when file is modified', () => {
|
||||
it('renders editor for modified file without breadcrumb status', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
@@ -161,11 +160,11 @@ describe('Editor', () => {
|
||||
getNoteStatus={() => 'modified'}
|
||||
/>
|
||||
)
|
||||
// Modified indicator shows "M" in the breadcrumb
|
||||
expect(screen.getByText('M')).toBeInTheDocument()
|
||||
// Editor still renders; breadcrumb bar no longer shows status indicators
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows new indicator when file is new', () => {
|
||||
it('renders editor for new file without breadcrumb status', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
@@ -174,8 +173,8 @@ describe('Editor', () => {
|
||||
getNoteStatus={() => 'new'}
|
||||
/>
|
||||
)
|
||||
// New indicator shows "N" in the breadcrumb
|
||||
expect(screen.getByText('N')).toBeInTheDocument()
|
||||
// Editor still renders; breadcrumb bar no longer shows status indicators
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders diff toggle button when file is modified', () => {
|
||||
@@ -468,7 +467,7 @@ describe('wikilink autocomplete', () => {
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
items[0].onItemClick()
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'Alpha Project' } },
|
||||
{ type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
@@ -621,7 +620,7 @@ describe('person @mention autocomplete', () => {
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
items[0].onItemClick()
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'Matteo Cellini' } },
|
||||
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -41,11 +41,13 @@ interface EditorProps {
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
vaultPath?: string
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
@@ -202,10 +204,10 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onToggleFavorite, onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onTitleSync,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
onSetNoteIcon, onRemoveNoteIcon,
|
||||
@@ -251,6 +253,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
onEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onTrashNote={onTrashNote}
|
||||
onRestoreNote={onRestoreNote}
|
||||
onDeleteNote={onDeleteNote}
|
||||
@@ -286,6 +289,8 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onToggleRawEditor={handleToggleRawExclusive}
|
||||
onOpenNote={onNavigateWikilink}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useRef, useEffect } from 'react'
|
||||
import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { DiffView } from './DiffView'
|
||||
@@ -13,6 +13,7 @@ import { RawEditorView } from './RawEditorView'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { useEditorTheme } from '../hooks/useTheme'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -40,6 +41,7 @@ interface EditorContentProps {
|
||||
onToggleInspector: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onEditorChange?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
@@ -91,7 +93,7 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
|
||||
}
|
||||
|
||||
function RawModeEditorSection({
|
||||
rawMode, activeTab, entries, onContentChange, onSave, latestContentRef,
|
||||
rawMode, activeTab, entries, onContentChange, onSave, latestContentRef, vaultPath,
|
||||
}: {
|
||||
rawMode: boolean
|
||||
activeTab: Tab | null
|
||||
@@ -99,6 +101,7 @@ function RawModeEditorSection({
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
latestContentRef?: React.MutableRefObject<string | null>
|
||||
vaultPath?: string
|
||||
}) {
|
||||
if (!rawMode || !activeTab) return null
|
||||
return (
|
||||
@@ -110,6 +113,7 @@ function RawModeEditorSection({
|
||||
onContentChange={onContentChange ?? (() => {})}
|
||||
onSave={onSave ?? (() => {})}
|
||||
latestContentRef={latestContentRef}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -119,8 +123,9 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
|
||||
return cb ? () => cb(path) : undefined
|
||||
}
|
||||
|
||||
function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
|
||||
activeTab: Tab
|
||||
barRef: React.RefObject<HTMLDivElement | null>
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
|
||||
}) {
|
||||
const wordCount = countWords(activeTab.content)
|
||||
@@ -129,7 +134,7 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
<BreadcrumbBar
|
||||
entry={activeTab.entry}
|
||||
wordCount={wordCount}
|
||||
noteStatus={props.activeStatus}
|
||||
barRef={barRef}
|
||||
showDiffToggle={props.showDiffToggle}
|
||||
diffMode={props.diffMode}
|
||||
diffLoading={props.diffLoading}
|
||||
@@ -140,6 +145,7 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
onToggleAIChat={props.onToggleAIChat}
|
||||
inspectorCollapsed={props.inspectorCollapsed}
|
||||
onToggleInspector={props.onToggleInspector}
|
||||
onToggleFavorite={bindPath(props.onToggleFavorite, path)}
|
||||
onTrash={bindPath(props.onTrashNote, path)}
|
||||
onRestore={bindPath(props.onRestoreNote, path)}
|
||||
onArchive={bindPath(props.onArchiveNote, path)}
|
||||
@@ -160,13 +166,35 @@ export function EditorContent({
|
||||
}: EditorContentProps) {
|
||||
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
|
||||
// so the banner appears regardless of navigation context.
|
||||
const { cssVars } = useEditorTheme()
|
||||
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
|
||||
const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false
|
||||
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
|
||||
const showEditor = !diffMode && !rawMode
|
||||
// Non-markdown text files always use the raw editor (no BlockNote)
|
||||
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
|
||||
const effectiveRawMode = rawMode || isNonMarkdownText
|
||||
const showEditor = !diffMode && !effectiveRawMode
|
||||
const entryIcon = activeTab?.entry.icon ?? null
|
||||
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
|
||||
|
||||
const titleSectionRef = useRef<HTMLDivElement | null>(null)
|
||||
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const el = titleSectionRef.current
|
||||
const bar = breadcrumbBarRef.current
|
||||
if (!el || !bar) return
|
||||
const observer = new IntersectionObserver(
|
||||
([e]) => {
|
||||
if (e.isIntersecting) bar.removeAttribute('data-title-hidden')
|
||||
else bar.setAttribute('data-title-hidden', '')
|
||||
},
|
||||
{ threshold: 0 },
|
||||
)
|
||||
observer.observe(el)
|
||||
return () => { observer.disconnect(); bar.removeAttribute('data-title-hidden') }
|
||||
}, [activeTab?.entry.path, showEditor])
|
||||
|
||||
const handleSetIcon = useCallback((emoji: string) => {
|
||||
if (activeTab) onSetNoteIcon?.(activeTab.entry.path, emoji)
|
||||
}, [activeTab, onSetNoteIcon])
|
||||
@@ -180,7 +208,8 @@ export function EditorContent({
|
||||
{activeTab && (
|
||||
<ActiveTabBreadcrumb
|
||||
activeTab={activeTab}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
barRef={breadcrumbBarRef}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
/>
|
||||
)}
|
||||
{activeTab && isTrashed && (
|
||||
@@ -199,19 +228,21 @@ export function EditorContent({
|
||||
/>
|
||||
)}
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} />
|
||||
<RawModeEditorSection rawMode={effectiveRawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} vaultPath={vaultPath} />
|
||||
{showEditor && activeTab && (
|
||||
<div className="editor-scroll-area">
|
||||
<div className="title-section">
|
||||
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
|
||||
<div ref={titleSectionRef} className="title-section">
|
||||
{!emojiIcon && (
|
||||
<NoteIcon
|
||||
icon={null}
|
||||
editable={!isTrashed}
|
||||
onSetIcon={handleSetIcon}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
/>
|
||||
<div className="title-section__add-icon">
|
||||
<NoteIcon
|
||||
icon={null}
|
||||
editable={!isTrashed}
|
||||
onSetIcon={handleSetIcon}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="title-section__row">
|
||||
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
|
||||
{emojiIcon && (
|
||||
<NoteIcon
|
||||
icon={emojiIcon}
|
||||
@@ -224,6 +255,8 @@ export function EditorContent({
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
editable={!isTrashed}
|
||||
notePath={activeTab.entry.path}
|
||||
vaultPath={vaultPath}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ interface EditorRightPanelProps {
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onToggleRawEditor?: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
@@ -33,14 +35,14 @@ export function EditorRightPanel({
|
||||
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
|
||||
noteList, noteListFilter,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onOpenNote,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
}: EditorRightPanelProps) {
|
||||
if (showAIChat) {
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 flex flex-col min-h-0"
|
||||
style={{ width: inspectorWidth, height: '100%' }}
|
||||
style={{ width: inspectorWidth, minWidth: 240, height: '100%' }}
|
||||
>
|
||||
<AiPanel
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
@@ -79,6 +81,8 @@ export function EditorRightPanel({
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onToggleRawEditor={onToggleRawEditor}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
241
src/components/FilterBuilder.tsx
Normal file
241
src/components/FilterBuilder.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useId } from 'react'
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'does not equal' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'not_contains', label: 'does not contain' },
|
||||
{ value: 'is_empty', label: 'is empty' },
|
||||
{ value: 'is_not_empty', label: 'is not empty' },
|
||||
{ value: 'before', label: 'before' },
|
||||
{ value: 'after', label: 'after' },
|
||||
]
|
||||
|
||||
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
|
||||
|
||||
function isFilterGroup(node: FilterNode): node is FilterGroup {
|
||||
return 'all' in node || 'any' in node
|
||||
}
|
||||
|
||||
function getGroupChildren(group: FilterGroup): FilterNode[] {
|
||||
return 'all' in group ? group.all : group.any
|
||||
}
|
||||
|
||||
function getGroupMode(group: FilterGroup): 'all' | 'any' {
|
||||
return 'all' in group ? 'all' : 'any'
|
||||
}
|
||||
|
||||
function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGroup {
|
||||
return mode === 'all' ? { all: children } : { any: children }
|
||||
}
|
||||
|
||||
/** Combobox-style field input with datalist autocomplete. */
|
||||
function FieldInput({ value, fields, onChange }: {
|
||||
value: string
|
||||
fields: string[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const id = useId()
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
list={id}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="field"
|
||||
/>
|
||||
<datalist id={id}>
|
||||
{fields.map((f) => <option key={f} value={f} />)}
|
||||
</datalist>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Combobox-style value input with autocomplete for known values. */
|
||||
function ValueInput({ value, suggestions, onChange }: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const id = useId()
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
list={id}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<datalist id={id}>
|
||||
{suggestions.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }: {
|
||||
condition: FilterCondition
|
||||
fields: string[]
|
||||
valueSuggestions: (field: string) => string[]
|
||||
onUpdate: (c: FilterCondition) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const suggestions = valueSuggestions(condition.field)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FieldInput
|
||||
value={condition.field}
|
||||
fields={fields}
|
||||
onChange={(v) => onUpdate({ ...condition, field: v })}
|
||||
/>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm shrink-0"
|
||||
value={condition.op}
|
||||
onChange={(e) => onUpdate({ ...condition, op: e.target.value as FilterOp })}
|
||||
>
|
||||
{OPERATORS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{!NO_VALUE_OPS.has(condition.op) && (
|
||||
<ValueInput
|
||||
value={String(condition.value ?? '')}
|
||||
suggestions={suggestions}
|
||||
onChange={(v) => onUpdate({ ...condition, value: v })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onRemove }: {
|
||||
group: FilterGroup
|
||||
fields: string[]
|
||||
valueSuggestions: (field: string) => string[]
|
||||
depth: number
|
||||
onChange: (g: FilterGroup) => void
|
||||
onRemove?: () => void
|
||||
}) {
|
||||
const mode = getGroupMode(group)
|
||||
const children = getGroupChildren(group)
|
||||
|
||||
const toggleMode = () => {
|
||||
onChange(setGroupChildren(mode === 'all' ? 'any' : 'all', children))
|
||||
}
|
||||
|
||||
const updateChild = (index: number, node: FilterNode) => {
|
||||
const next = [...children]
|
||||
next[index] = node
|
||||
onChange(setGroupChildren(mode, next))
|
||||
}
|
||||
|
||||
const removeChild = (index: number) => {
|
||||
const next = children.filter((_, i) => i !== index)
|
||||
onChange(setGroupChildren(mode, next))
|
||||
}
|
||||
|
||||
const addCondition = () => {
|
||||
onChange(setGroupChildren(mode, [...children, { field: fields[0] ?? 'type', op: 'equals' as FilterOp, value: '' }]))
|
||||
}
|
||||
|
||||
const addGroup = () => {
|
||||
const nested: FilterGroup = { all: [{ field: fields[0] ?? 'type', op: 'equals' as FilterOp, value: '' }] }
|
||||
onChange(setGroupChildren(mode, [...children, nested]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={depth > 0 ? 'ml-3 border-l-2 border-border pl-3 py-1' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-input bg-muted px-2.5 py-0.5 text-[11px] font-medium text-foreground cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={toggleMode}
|
||||
title={`Switch to ${mode === 'all' ? 'OR' : 'AND'}`}
|
||||
>
|
||||
{mode === 'all' ? 'AND' : 'OR'}
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{mode === 'all' ? 'Match all conditions' : 'Match any condition'}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove group"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{children.map((child, i) =>
|
||||
isFilterGroup(child) ? (
|
||||
<FilterGroupView
|
||||
key={i}
|
||||
group={child}
|
||||
fields={fields}
|
||||
valueSuggestions={valueSuggestions}
|
||||
depth={depth + 1}
|
||||
onChange={(g) => updateChild(i, g)}
|
||||
onRemove={() => removeChild(i)}
|
||||
/>
|
||||
) : (
|
||||
<FilterRow
|
||||
key={i}
|
||||
condition={child}
|
||||
fields={fields}
|
||||
valueSuggestions={valueSuggestions}
|
||||
onUpdate={(c) => updateChild(i, c)}
|
||||
onRemove={() => removeChild(i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={addCondition}>
|
||||
<Plus size={12} className="mr-1" /> Add filter
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={addGroup}>
|
||||
<Plus size={12} className="mr-1" /> Add group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FilterBuilderProps {
|
||||
group: FilterGroup
|
||||
onChange: (group: FilterGroup) => void
|
||||
availableFields: string[]
|
||||
/** Returns known values for a given field (for autocomplete). */
|
||||
valueSuggestions?: (field: string) => string[]
|
||||
}
|
||||
|
||||
const defaultSuggestions = () => [] as string[]
|
||||
|
||||
export function FilterBuilder({ group, onChange, availableFields, valueSuggestions }: FilterBuilderProps) {
|
||||
const fields = availableFields.length > 0 ? availableFields : ['type']
|
||||
return (
|
||||
<FilterGroupView
|
||||
group={group}
|
||||
fields={fields}
|
||||
valueSuggestions={valueSuggestions ?? defaultSuggestions}
|
||||
depth={0}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
interface FlatVaultMigrationBannerProps {
|
||||
strayFileCount: number
|
||||
isMigrating: boolean
|
||||
onMigrate: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner shown when the vault has files in non-protected subfolders.
|
||||
* Offers to flatten them to the vault root.
|
||||
*/
|
||||
export function FlatVaultMigrationBanner({ strayFileCount, isMigrating, onMigrate, onDismiss }: FlatVaultMigrationBannerProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-amber-50 dark:bg-amber-950 border-b border-amber-200 dark:border-amber-800 text-sm" data-testid="migration-banner">
|
||||
<span className="flex-1 text-amber-800 dark:text-amber-200">
|
||||
{strayFileCount} note{strayFileCount !== 1 ? 's' : ''} found in subfolders.
|
||||
Flatten to vault root for consistent scanning.
|
||||
</span>
|
||||
<button
|
||||
className="px-3 py-1 text-xs font-medium rounded bg-amber-600 text-white hover:bg-amber-700 disabled:opacity-50"
|
||||
onClick={onMigrate}
|
||||
disabled={isMigrating}
|
||||
data-testid="migration-flatten-btn"
|
||||
>
|
||||
{isMigrating ? 'Migrating...' : 'Flatten Now'}
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-1 text-xs text-amber-600 dark:text-amber-400 hover:underline"
|
||||
onClick={onDismiss}
|
||||
disabled={isMigrating}
|
||||
data-testid="migration-dismiss-btn"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
src/components/FolderTree.test.tsx
Normal file
70
src/components/FolderTree.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { FolderTree } from './FolderTree'
|
||||
import type { FolderNode, SidebarSelection } from '../types'
|
||||
|
||||
const mockFolders: FolderNode[] = [
|
||||
{
|
||||
name: 'projects',
|
||||
path: 'projects',
|
||||
children: [
|
||||
{ name: 'laputa', path: 'projects/laputa', children: [] },
|
||||
{ name: 'portfolio', path: 'projects/portfolio', children: [] },
|
||||
],
|
||||
},
|
||||
{ name: 'areas', path: 'areas', children: [] },
|
||||
{ name: 'journal', path: 'journal', children: [] },
|
||||
]
|
||||
|
||||
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
describe('FolderTree', () => {
|
||||
it('renders nothing when folders is empty', () => {
|
||||
const { container } = render(
|
||||
<FolderTree folders={[]} selection={defaultSelection} onSelect={vi.fn()} />,
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders FOLDERS header and top-level folders', () => {
|
||||
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
|
||||
expect(screen.getByText('FOLDERS')).toBeInTheDocument()
|
||||
expect(screen.getByText('projects')).toBeInTheDocument()
|
||||
expect(screen.getByText('areas')).toBeInTheDocument()
|
||||
expect(screen.getByText('journal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show children initially', () => {
|
||||
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
|
||||
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with folder kind when clicking a folder', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByText('projects'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' })
|
||||
})
|
||||
|
||||
it('expands children when clicking a folder with children', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByText('projects'))
|
||||
expect(screen.getByText('laputa')).toBeInTheDocument()
|
||||
expect(screen.getByText('portfolio')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses section when clicking FOLDERS header', () => {
|
||||
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
|
||||
expect(screen.getByText('projects')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('FOLDERS'))
|
||||
expect(screen.queryByText('projects')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('highlights selected folder', () => {
|
||||
const sel: SidebarSelection = { kind: 'folder', path: 'areas' }
|
||||
render(<FolderTree folders={mockFolders} selection={sel} onSelect={vi.fn()} />)
|
||||
const btn = screen.getByText('areas').closest('button')!
|
||||
expect(btn.className).toContain('text-primary')
|
||||
})
|
||||
})
|
||||
159
src/components/FolderTree.tsx
Normal file
159
src/components/FolderTree.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState, useCallback, useRef, useEffect, memo } from 'react'
|
||||
import { Folder, FolderOpen, CaretDown, CaretRight, Plus } from '@phosphor-icons/react'
|
||||
import type { FolderNode, SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FolderTreeProps {
|
||||
folders: FolderNode[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
onCreateFolder?: (name: string) => void
|
||||
}
|
||||
|
||||
function FolderItem({
|
||||
node, depth, selection, expanded, onToggle, onSelect,
|
||||
}: {
|
||||
node: FolderNode
|
||||
depth: number
|
||||
selection: SidebarSelection
|
||||
expanded: Record<string, boolean>
|
||||
onToggle: (path: string) => void
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
}) {
|
||||
const isSelected = selection.kind === 'folder' && selection.path === node.path
|
||||
const isExpanded = expanded[node.path] ?? false
|
||||
const hasChildren = node.children.length > 0
|
||||
|
||||
const handleClick = () => {
|
||||
onSelect({ kind: 'folder', path: node.path })
|
||||
if (hasChildren) onToggle(node.path)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-[5px] border-none bg-transparent cursor-pointer text-left transition-colors',
|
||||
isSelected
|
||||
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
|
||||
: 'text-foreground hover:bg-accent',
|
||||
)}
|
||||
style={{ padding: '5px 8px', paddingLeft: 8 + depth * 16, fontSize: 13 }}
|
||||
onClick={handleClick}
|
||||
title={node.path}
|
||||
>
|
||||
{isSelected || isExpanded ? (
|
||||
<FolderOpen size={18} weight="fill" className="shrink-0" />
|
||||
) : (
|
||||
<Folder size={18} className="shrink-0" />
|
||||
)}
|
||||
<span className={cn('truncate', isSelected && 'font-medium')}>{node.name}</span>
|
||||
</button>
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="relative" style={{ paddingLeft: 15 }}>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-border"
|
||||
style={{ left: 15 + depth * 16, width: 1, opacity: 0.3 }}
|
||||
/>
|
||||
{node.children.map((child) => (
|
||||
<FolderItem
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
selection={selection}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder }: FolderTreeProps) {
|
||||
const [sectionCollapsed, setSectionCollapsed] = useState(false)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [newFolderName, setNewFolderName] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setExpanded((prev) => ({ ...prev, [path]: !prev[path] }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreating) inputRef.current?.focus()
|
||||
}, [isCreating])
|
||||
|
||||
const handleCreateFolder = () => {
|
||||
const name = newFolderName.trim()
|
||||
if (name && onCreateFolder) {
|
||||
onCreateFolder(name)
|
||||
}
|
||||
setIsCreating(false)
|
||||
setNewFolderName('')
|
||||
}
|
||||
|
||||
if (folders.length === 0 && !isCreating) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
{/* Header */}
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
onClick={() => setSectionCollapsed((v) => !v)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{sectionCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>FOLDERS</span>
|
||||
</div>
|
||||
{onCreateFolder && (
|
||||
<Plus
|
||||
size={12}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); setIsCreating(true); setSectionCollapsed(false) }}
|
||||
data-testid="create-folder-btn"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tree */}
|
||||
{!sectionCollapsed && (
|
||||
<div className="flex flex-col gap-0.5" style={{ padding: '2px 6px 8px 14px' }}>
|
||||
{folders.map((node) => (
|
||||
<FolderItem
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
selection={selection}
|
||||
expanded={expanded}
|
||||
onToggle={toggleFolder}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
{isCreating && (
|
||||
<div className="flex items-center gap-2" style={{ padding: '4px 8px' }}>
|
||||
<Folder size={18} className="shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 border border-border rounded bg-background px-1.5 py-0.5 text-[13px] text-foreground outline-none focus:border-primary"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateFolder()
|
||||
if (e.key === 'Escape') { setIsCreating(false); setNewFolderName('') }
|
||||
}}
|
||||
onBlur={handleCreateFolder}
|
||||
placeholder="Folder name"
|
||||
data-testid="new-folder-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
51
src/components/GitRequiredModal.test.tsx
Normal file
51
src/components/GitRequiredModal.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { GitRequiredModal } from './GitRequiredModal'
|
||||
|
||||
describe('GitRequiredModal', () => {
|
||||
it('renders title and explanation', () => {
|
||||
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
|
||||
expect(screen.getByText('Git repository required')).toBeInTheDocument()
|
||||
expect(screen.getByText(/track changes/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders both action buttons', () => {
|
||||
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
|
||||
expect(screen.getByText('Create repository')).toBeInTheDocument()
|
||||
expect(screen.getByText('Choose another vault')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreateRepo when primary button clicked', async () => {
|
||||
const onCreateRepo = vi.fn().mockResolvedValue(undefined)
|
||||
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Create repository'))
|
||||
expect(onCreateRepo).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onChooseVault when secondary button clicked', () => {
|
||||
const onChooseVault = vi.fn()
|
||||
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={onChooseVault} />)
|
||||
fireEvent.click(screen.getByText('Choose another vault'))
|
||||
expect(onChooseVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables buttons and shows spinner while creating', async () => {
|
||||
let resolve: () => void
|
||||
const onCreateRepo = vi.fn().mockReturnValue(new Promise<void>(r => { resolve = r }))
|
||||
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Create repository'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Creating…')).toBeInTheDocument()
|
||||
})
|
||||
resolve!()
|
||||
})
|
||||
|
||||
it('shows error message when creation fails', async () => {
|
||||
const onCreateRepo = vi.fn().mockRejectedValue(new Error('Permission denied'))
|
||||
render(<GitRequiredModal onCreateRepo={onCreateRepo} onChooseVault={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Create repository'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Permission denied/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
57
src/components/GitRequiredModal.tsx
Normal file
57
src/components/GitRequiredModal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
import { GitBranch } from '@phosphor-icons/react'
|
||||
|
||||
interface GitRequiredModalProps {
|
||||
onCreateRepo: () => Promise<void>
|
||||
onChooseVault: () => void
|
||||
}
|
||||
|
||||
export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredModalProps) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onCreateRepo()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center" style={{ background: 'var(--sidebar)' }}>
|
||||
<div className="flex max-w-sm flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
|
||||
<GitBranch size={36} className="text-muted-foreground" />
|
||||
<h2 className="m-0 text-lg font-semibold text-foreground">Git repository required</h2>
|
||||
<p className="m-0 text-center text-[13px] leading-relaxed text-muted-foreground">
|
||||
Laputa uses a git repository to track changes, detect moved files, and keep your vault safe.
|
||||
We'll create a local repo — no remote needed.
|
||||
</p>
|
||||
{error && (
|
||||
<p className="m-0 rounded-md bg-destructive/10 px-3 py-2 text-center text-[12px] text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<button
|
||||
className="w-full cursor-pointer rounded-md bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create repository'}
|
||||
</button>
|
||||
<button
|
||||
className="w-full cursor-pointer rounded-md border border-border bg-transparent px-4 py-2 text-[13px] font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onChooseVault}
|
||||
disabled={creating}
|
||||
>
|
||||
Choose another vault
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -619,4 +619,96 @@ Status: Active
|
||||
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('frontmatter state handling', () => {
|
||||
const noFrontmatterEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/plain-note.md',
|
||||
filename: 'plain-note.md',
|
||||
title: 'plain-note',
|
||||
isA: null,
|
||||
}
|
||||
|
||||
it('shows "Initialize properties" button when note has no frontmatter', () => {
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={noFrontmatterEntry}
|
||||
content="# Just a plain note\n\nNo frontmatter here."
|
||||
onInitializeProperties={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Initialize properties')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Type')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Initialize properties" button when frontmatter is empty', () => {
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={noFrontmatterEntry}
|
||||
content="---\n---\n# Note with empty frontmatter"
|
||||
onInitializeProperties={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Initialize properties')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onInitializeProperties when button is clicked', () => {
|
||||
const onInit = vi.fn()
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={noFrontmatterEntry}
|
||||
content="# Plain note"
|
||||
onInitializeProperties={onInit}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Initialize properties'))
|
||||
expect(onInit).toHaveBeenCalledWith('/vault/plain-note.md')
|
||||
})
|
||||
|
||||
it('shows invalid frontmatter notice with fix button', () => {
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={noFrontmatterEntry}
|
||||
content={'---\n{broken yaml\n---\nBody'}
|
||||
onToggleRawEditor={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Invalid properties')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fix in editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggleRawEditor when fix button is clicked', () => {
|
||||
const onToggle = vi.fn()
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={noFrontmatterEntry}
|
||||
content={'---\n{broken yaml\n---\nBody'}
|
||||
onToggleRawEditor={onToggle}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Fix in editor'))
|
||||
expect(onToggle).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('still shows backlinks and history for notes without frontmatter', () => {
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={noFrontmatterEntry}
|
||||
content="# Plain note"
|
||||
entries={[noFrontmatterEntry, { ...referrerEntry, outgoingLinks: ['plain-note'] }]}
|
||||
gitHistory={mockGitHistory}
|
||||
onInitializeProperties={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Initialize properties')).toBeInTheDocument()
|
||||
expect(screen.getByText('Backlinks')).toBeInTheDocument()
|
||||
expect(screen.getByText('History')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useMemo, useCallback } from 'react'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SlidersHorizontal, X } from '@phosphor-icons/react'
|
||||
import { SlidersHorizontal, X, Sparkle, WarningCircle, PencilSimple } from '@phosphor-icons/react'
|
||||
import { Separator } from './ui/separator'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { parseFrontmatter, detectFrontmatterState } from '../utils/frontmatter'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||
import { wikilinkTarget } from '../utils/wikilink'
|
||||
@@ -25,6 +25,17 @@ interface InspectorProps {
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onCreateAndOpenNote?: (title: string) => Promise<boolean>
|
||||
onInitializeProperties?: (path: string) => void
|
||||
onToggleRawEditor?: () => void
|
||||
}
|
||||
|
||||
function targetMatchesEntry(target: string, entryPath: string, matchTargets: Set<string>): boolean {
|
||||
if (matchTargets.has(target)) return true
|
||||
const lastSegment = target.split('/').pop() ?? ''
|
||||
if (matchTargets.has(lastSegment)) return true
|
||||
// Path-suffix match: "projects/alpha" matches entry path ending with "/projects/alpha.md"
|
||||
if (target.includes('/') && entryPath.toLowerCase().endsWith('/' + target.toLowerCase() + '.md')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function useBacklinks(
|
||||
@@ -37,7 +48,6 @@ function useBacklinks(
|
||||
const matchTargets = new Set([
|
||||
entry.title, ...entry.aliases,
|
||||
entry.filename.replace(/\.md$/, ''),
|
||||
entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, ''),
|
||||
])
|
||||
|
||||
const referencedByPaths = new Set(referencedBy.map((item) => item.entry.path))
|
||||
@@ -46,9 +56,7 @@ function useBacklinks(
|
||||
.filter((e) => {
|
||||
if (e.path === entry.path) return false
|
||||
if (referencedByPaths.has(e.path)) return false
|
||||
return e.outgoingLinks.some((target) =>
|
||||
matchTargets.has(target) || matchTargets.has(target.split('/').pop() ?? '')
|
||||
)
|
||||
return e.outgoingLinks.some((target) => targetMatchesEntry(target, entry.path, matchTargets))
|
||||
})
|
||||
.map((e) => ({
|
||||
entry: e,
|
||||
@@ -68,9 +76,8 @@ function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): Refer
|
||||
return useMemo(() => {
|
||||
if (!entry) return []
|
||||
|
||||
const pathStem = entry.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
|
||||
const filenameStem = entry.filename.replace(/\.md$/, '')
|
||||
const matchTargets = new Set([pathStem, filenameStem, entry.title, ...entry.aliases])
|
||||
const matchTargets = new Set([filenameStem, entry.title, ...entry.aliases])
|
||||
|
||||
const results: ReferencedByItem[] = []
|
||||
|
||||
@@ -90,16 +97,16 @@ function useReferencedBy(entry: VaultEntry | null, entries: VaultEntry[]): Refer
|
||||
function InspectorHeader({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
|
||||
const { onMouseDown } = useDragRegion()
|
||||
return (
|
||||
<div className="flex shrink-0 items-center border-b border-border" style={{ height: 45, padding: '6px 12px', gap: 8, cursor: 'default' }} onMouseDown={onMouseDown}>
|
||||
<div className="flex shrink-0 items-center border-b border-border" style={{ height: 52, padding: '6px 12px', gap: 8, cursor: 'default' }} onMouseDown={onMouseDown}>
|
||||
{collapsed ? (
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle}>
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle} title="Properties (⌘⇧I)">
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<SlidersHorizontal size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>Properties</span>
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle}>
|
||||
<button className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground" onClick={onToggle} title="Close Properties (⌘⇧I)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</>
|
||||
@@ -114,13 +121,46 @@ function EmptyInspector() {
|
||||
)
|
||||
}
|
||||
|
||||
function InitializePropertiesPrompt({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border px-4 py-6">
|
||||
<Sparkle size={24} className="text-muted-foreground" />
|
||||
<p className="m-0 text-center text-[13px] text-muted-foreground">This note has no properties yet</p>
|
||||
<button
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
|
||||
onClick={onClick}
|
||||
>
|
||||
Initialize properties
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InvalidFrontmatterNotice({ onFix }: { onFix: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-destructive/40 bg-destructive/5 px-4 py-6">
|
||||
<WarningCircle size={24} className="text-destructive" />
|
||||
<p className="m-0 text-center text-[13px] text-muted-foreground">Invalid properties</p>
|
||||
<button
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-[13px] font-medium text-foreground transition-colors hover:bg-muted"
|
||||
onClick={onFix}
|
||||
>
|
||||
<PencilSimple size={14} />
|
||||
Fix in editor
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote,
|
||||
onInitializeProperties, onToggleRawEditor,
|
||||
}: InspectorProps) {
|
||||
const referencedBy = useReferencedBy(entry, entries)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy)
|
||||
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
|
||||
const fmState = useMemo(() => detectFrontmatterState(content), [content])
|
||||
const typeEntryMap = useMemo(() => {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
for (const e of entries) { if (e.isA === 'Type') map[e.title] = e }
|
||||
@@ -146,23 +186,31 @@ export function Inspector({
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
|
||||
{entry ? (
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry} content={content} frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<DynamicRelationshipsPanel
|
||||
frontmatter={frontmatter} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
|
||||
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} />
|
||||
{fmState === 'valid' ? (
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry} content={content} frontmatter={frontmatter}
|
||||
entries={entries}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<DynamicRelationshipsPanel
|
||||
frontmatter={frontmatter} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
|
||||
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} />
|
||||
</>
|
||||
) : fmState === 'invalid' ? (
|
||||
onToggleRawEditor && <InvalidFrontmatterNotice onFix={onToggleRawEditor} />
|
||||
) : (
|
||||
onInitializeProperties && <InitializePropertiesPrompt onClick={() => onInitializeProperties(entry.path)} />
|
||||
)}
|
||||
{backlinks.length > 0 && <Separator />}
|
||||
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
|
||||
{gitHistory.length > 0 && <Separator />}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, FileText, StackSimple,
|
||||
File, FileDashed,
|
||||
} from '@phosphor-icons/react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
@@ -91,6 +92,12 @@ function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor:
|
||||
return base
|
||||
}
|
||||
|
||||
function getFileKindIcon(fileKind: string | undefined): ComponentType<SVGAttributes<SVGSVGElement>> {
|
||||
if (fileKind === 'text') return File
|
||||
if (fileKind === 'binary') return FileDashed
|
||||
return FileText
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote, onPrefetch }: {
|
||||
entry: VaultEntry
|
||||
isSelected: boolean
|
||||
@@ -101,44 +108,55 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
onPrefetch?: (path: string) => void
|
||||
}) {
|
||||
const isBinary = entry.fileKind === 'binary'
|
||||
const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown'
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
|
||||
const TypeIcon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon])
|
||||
const TypeIcon = useMemo(() => {
|
||||
if (isNonMarkdown) return getFileKindIcon(entry.fileKind)
|
||||
return getTypeIcon(entry.isA, te?.icon)
|
||||
}, [entry.isA, te?.icon, entry.fileKind, isNonMarkdown])
|
||||
|
||||
const handleClick = isBinary
|
||||
? (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation() }
|
||||
: (e: React.MouseEvent) => onClickNote(entry, e)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
|
||||
isSelected && !isMultiSelected && "border-l-[3px]",
|
||||
!isSelected && !isMultiSelected && "hover:bg-muted",
|
||||
isHighlighted && !isSelected && !isMultiSelected && "bg-muted"
|
||||
"relative border-b border-[var(--border)] transition-colors",
|
||||
isBinary ? "cursor-default opacity-50" : "cursor-pointer",
|
||||
isSelected && !isMultiSelected && !isBinary && "border-l-[3px]",
|
||||
!isSelected && !isMultiSelected && !isBinary && "hover:bg-muted",
|
||||
isHighlighted && !isSelected && !isMultiSelected && !isBinary && "bg-muted"
|
||||
)}
|
||||
style={noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
|
||||
onClick={(e: React.MouseEvent) => onClickNote(entry, e)}
|
||||
onMouseEnter={onPrefetch ? () => onPrefetch(entry.path) : undefined}
|
||||
data-testid={isMultiSelected ? 'multi-selected-item' : undefined}
|
||||
style={isBinary ? { padding: '14px 16px' } : noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={!isBinary && onPrefetch ? () => onPrefetch(entry.path) : undefined}
|
||||
data-testid={isMultiSelected ? 'multi-selected-item' : isBinary ? 'binary-file-item' : undefined}
|
||||
data-highlighted={isHighlighted || undefined}
|
||||
title={isBinary ? 'Cannot open this file type' : undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
<div className="pr-5">
|
||||
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
|
||||
{noteStatus !== 'clean' && <StatusDot noteStatus={noteStatus} />}
|
||||
<div className={cn("truncate text-[13px]", isBinary ? "text-muted-foreground" : "text-foreground", isSelected && !isBinary ? "font-semibold" : "font-medium")}>
|
||||
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
<StateBadge archived={entry.archived} trashed={entry.trashed} />
|
||||
{!isBinary && <StateBadge archived={entry.archived} trashed={entry.trashed} />}
|
||||
</div>
|
||||
</div>
|
||||
{entry.snippet && (
|
||||
{entry.snippet && !isBinary && (
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
)}
|
||||
{entry.trashed && entry.trashedAt
|
||||
{!isBinary && (entry.trashed && entry.trashedAt
|
||||
? <TrashDateLine entry={entry} />
|
||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NoteList } from './NoteList'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import { getSortComparator, filterEntries, countByFilter, countAllByFilter } from '../utils/noteListHelpers'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
@@ -208,6 +209,20 @@ describe('NoteList', () => {
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes selected type when creating note from type section', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={onCreate} />)
|
||||
fireEvent.click(screen.getByTitle('Create new note'))
|
||||
expect(onCreate).toHaveBeenCalledWith('Project')
|
||||
})
|
||||
|
||||
it('passes undefined type when creating note from All Notes', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<NoteList {...defaultFilterProps} entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={onCreate} />)
|
||||
fireEvent.click(screen.getByTitle('Create new note'))
|
||||
expect(onCreate).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('shows entity pinned at top with grouped children', () => {
|
||||
render(
|
||||
<NoteList {...defaultFilterProps} entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
@@ -867,7 +882,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
expect(screen.getByText('Note 499')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('search filters large dataset correctly', () => {
|
||||
it('search filters large dataset correctly', { timeout: 15000 }, () => {
|
||||
const entries = [
|
||||
makeIndexedEntry(0, { title: 'Alpha Strategy' }),
|
||||
...Array.from({ length: 998 }, (_, i) => makeIndexedEntry(i + 1, { title: `Filler Note ${i + 1}` })),
|
||||
@@ -1439,4 +1454,44 @@ describe('countAllByFilter', () => {
|
||||
const counts = countAllByFilter(entries)
|
||||
expect(counts).toEqual({ open: 2, archived: 1, trashed: 2 })
|
||||
})
|
||||
|
||||
it('excludes non-markdown files from counts', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', isA: 'Note', fileKind: 'markdown' }),
|
||||
makeEntry({ path: '/2.yml', isA: undefined, fileKind: 'text' }),
|
||||
makeEntry({ path: '/3.png', isA: undefined, fileKind: 'binary' }),
|
||||
]
|
||||
const counts = countAllByFilter(entries)
|
||||
expect(counts).toEqual({ open: 1, archived: 0, trashed: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteItem — binary file rendering', () => {
|
||||
it('renders binary files as non-clickable with muted style', () => {
|
||||
const binaryEntry = makeEntry({
|
||||
path: '/vault/photo.png', filename: 'photo.png', title: 'photo.png', fileKind: 'binary',
|
||||
})
|
||||
const onClick = vi.fn()
|
||||
render(<NoteItem entry={binaryEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClick} />)
|
||||
|
||||
const item = screen.getByTestId('binary-file-item')
|
||||
expect(item).toBeTruthy()
|
||||
expect(item.className).toContain('opacity-50')
|
||||
expect(item).toHaveAttribute('title', 'Cannot open this file type')
|
||||
|
||||
fireEvent.click(item)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders text files as clickable', () => {
|
||||
const textEntry = makeEntry({
|
||||
path: '/vault/config.yml', filename: 'config.yml', title: 'config.yml', fileKind: 'text',
|
||||
})
|
||||
const onClick = vi.fn()
|
||||
render(<NoteItem entry={textEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClick} />)
|
||||
|
||||
const item = screen.getByText('config.yml').closest('div')!
|
||||
fireEvent.click(item)
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod } from '../types'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
|
||||
import { NoteItem } from './NoteItem'
|
||||
@@ -32,7 +32,7 @@ interface NoteListProps {
|
||||
sidebarCollapsed?: boolean
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onCreateNote: () => void
|
||||
onCreateNote: (type?: string) => void
|
||||
onBulkArchive?: (paths: string[]) => void
|
||||
onBulkTrash?: (paths: string[]) => void
|
||||
onBulkRestore?: (paths: string[]) => void
|
||||
@@ -41,20 +41,22 @@ interface NoteListProps {
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
views?: ViewFile[]
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, views }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
const isFolderView = selection.kind === 'folder'
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
|
||||
const showFilterPills = isSectionGroup || isAllNotesView
|
||||
const showFilterPills = isSectionGroup || isFolderView || isAllNotesView
|
||||
const subFilter = showFilterPills ? noteListFilter : undefined
|
||||
|
||||
const filterCounts = useMemo(
|
||||
() => isSectionGroup ? countByFilter(entries, selection.type) : isAllNotesView ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
|
||||
[entries, isSectionGroup, isAllNotesView, selection],
|
||||
() => isSectionGroup ? countByFilter(entries, selection.type) : (isAllNotesView || isFolderView) ? countAllByFilter(entries) : { open: 0, archived: 0, trashed: 0 },
|
||||
[entries, isSectionGroup, isAllNotesView, isFolderView, selection],
|
||||
)
|
||||
|
||||
const inboxCounts = useMemo(
|
||||
@@ -67,7 +69,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined })
|
||||
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
const deletedCount = useMemo(
|
||||
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
|
||||
@@ -96,15 +98,16 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} />
|
||||
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
|
||||
|
||||
const handleCreateNote = useCallback(() => {
|
||||
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
|
||||
}, [onCreateNote, selection])
|
||||
const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
|
||||
const title = resolveHeaderTitle(selection, typeDocument)
|
||||
const title = resolveHeaderTitle(selection, typeDocument, views)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} />}
|
||||
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} />}
|
||||
<div className="flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
<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={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
{entitySelection ? (
|
||||
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
|
||||
@@ -113,6 +116,8 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
)}
|
||||
</div>
|
||||
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
|
||||
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} position="bottom" />}
|
||||
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} position="bottom" />}
|
||||
</div>
|
||||
{multiSelect.isMultiSelecting && (
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
|
||||
|
||||
@@ -42,7 +42,7 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
|
||||
return (
|
||||
<span className="relative inline-flex min-w-0 items-center">
|
||||
<span
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium transition-opacity hover:opacity-80"
|
||||
className="inline-flex h-6 cursor-pointer items-center gap-1.5 rounded-md px-2 text-[12px] font-medium transition-opacity hover:opacity-80"
|
||||
style={{ backgroundColor: style.bg, color: style.color }}
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
data-testid="status-badge"
|
||||
@@ -83,8 +83,8 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className="group/tag relative inline-flex items-center overflow-hidden rounded-md"
|
||||
style={{ backgroundColor: style.bg, padding: '2px 8px', maxWidth: 120 }}
|
||||
className="group/tag relative inline-flex h-6 items-center overflow-hidden rounded-md"
|
||||
style={{ backgroundColor: style.bg, padding: '0 8px', maxWidth: 120 }}
|
||||
>
|
||||
<span
|
||||
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
|
||||
@@ -110,8 +110,8 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
|
||||
)
|
||||
})}
|
||||
<button
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-md border-none bg-muted text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
style={{ padding: '2px 8px' }}
|
||||
className="inline-flex h-6 shrink-0 items-center justify-center rounded-md border-none bg-muted text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
style={{ padding: '0 8px' }}
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
title="Add tag"
|
||||
data-testid="tags-add-button"
|
||||
@@ -130,7 +130,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
|
||||
|
||||
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5" data-testid="boolean-toggle">
|
||||
<label className="inline-flex h-6 cursor-pointer items-center gap-1.5" data-testid="boolean-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
@@ -164,7 +164,7 @@ function DateValue({ value, onSave }: {
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={`inline-flex min-w-0 cursor-pointer items-center gap-1 border-none px-2 py-1 text-right text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded-md bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
|
||||
className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 border-none px-2 text-right text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded-md bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
|
||||
title={value}
|
||||
data-testid="date-display"
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface RawEditorViewProps {
|
||||
path: string
|
||||
entries: VaultEntry[]
|
||||
onContentChange: (path: string, content: string) => void
|
||||
vaultPath?: string
|
||||
onSave: () => void
|
||||
/** Mutable ref updated on every keystroke with the latest doc string.
|
||||
* Allows the parent to flush debounced content before unmount. */
|
||||
@@ -37,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, latestContentRef }: RawEditorViewProps) {
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pathRef = useRef(path)
|
||||
@@ -92,10 +93,10 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
const coords = getCursorCoords(view)
|
||||
if (!coords) { setAutocomplete(null); return }
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title))
|
||||
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title), vaultPath ?? '')
|
||||
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
|
||||
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
|
||||
}, [baseItems, typeEntryMap])
|
||||
}, [baseItems, typeEntryMap, vaultPath])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (debounceRef.current) {
|
||||
|
||||
41
src/components/RenameDetectedBanner.test.tsx
Normal file
41
src/components/RenameDetectedBanner.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { RenameDetectedBanner } from './RenameDetectedBanner'
|
||||
|
||||
describe('RenameDetectedBanner', () => {
|
||||
const renames = [
|
||||
{ old_path: 'old-note.md', new_path: 'new-note.md' },
|
||||
{ old_path: 'folder/draft.md', new_path: 'folder/published.md' },
|
||||
]
|
||||
|
||||
it('renders nothing when renames is empty', () => {
|
||||
const { container } = render(
|
||||
<RenameDetectedBanner renames={[]} onUpdate={vi.fn()} onDismiss={vi.fn()} />
|
||||
)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('shows banner with rename count', () => {
|
||||
render(<RenameDetectedBanner renames={renames} onUpdate={vi.fn()} onDismiss={vi.fn()} />)
|
||||
expect(screen.getByText(/2 files? renamed/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Update wikilinks button', () => {
|
||||
render(<RenameDetectedBanner renames={renames} onUpdate={vi.fn()} onDismiss={vi.fn()} />)
|
||||
expect(screen.getByText('Update wikilinks')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onUpdate when Update button clicked', () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(<RenameDetectedBanner renames={renames} onUpdate={onUpdate} onDismiss={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Update wikilinks'))
|
||||
expect(onUpdate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onDismiss when Ignore button clicked', () => {
|
||||
const onDismiss = vi.fn()
|
||||
render(<RenameDetectedBanner renames={renames} onUpdate={vi.fn()} onDismiss={onDismiss} />)
|
||||
fireEvent.click(screen.getByText('Ignore'))
|
||||
expect(onDismiss).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
38
src/components/RenameDetectedBanner.tsx
Normal file
38
src/components/RenameDetectedBanner.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ArrowsClockwise } from '@phosphor-icons/react'
|
||||
|
||||
export interface DetectedRename {
|
||||
old_path: string
|
||||
new_path: string
|
||||
}
|
||||
|
||||
interface RenameDetectedBannerProps {
|
||||
renames: DetectedRename[]
|
||||
onUpdate: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function RenameDetectedBanner({ renames, onUpdate, onDismiss }: RenameDetectedBannerProps) {
|
||||
if (renames.length === 0) return null
|
||||
|
||||
const count = renames.length
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border bg-accent/50 px-4 py-2 text-[13px]">
|
||||
<ArrowsClockwise size={16} className="shrink-0 text-accent-foreground" />
|
||||
<span className="flex-1 text-foreground">
|
||||
{count} file{count !== 1 ? 's' : ''} renamed outside Laputa. Update wikilinks?
|
||||
</span>
|
||||
<button
|
||||
className="shrink-0 cursor-pointer rounded-md bg-primary px-3 py-1 text-[12px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
onClick={onUpdate}
|
||||
>
|
||||
Update wikilinks
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 cursor-pointer rounded-md border border-border bg-transparent px-3 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="-ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
|
||||
className="relative z-10 -ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -154,6 +154,28 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows note title from VaultEntry instead of filename from search result', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'ai-apis', path: '/vault/essay/ai-apis.md', snippet: '...designing APIs...', score: 0.87, note_type: null },
|
||||
],
|
||||
elapsed_ms: 12,
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('Search in all notes...')
|
||||
fireEvent.change(input, { target: { value: 'api' } })
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show VaultEntry title, not filename-based search result title
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
expect(screen.queryByText('ai-apis')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no results message when search returns empty', async () => {
|
||||
mockInvokeFn.mockResolvedValue({ results: [], elapsed_ms: 10 })
|
||||
|
||||
@@ -186,13 +208,13 @@ describe('SearchPanel', () => {
|
||||
fireEvent.change(input, { target: { value: 'test' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Result One')).toBeInTheDocument()
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
|
||||
await waitFor(() => {
|
||||
const resultTwo = screen.getByText('Result Two').closest('[class*="cursor-pointer"]')!
|
||||
const resultTwo = screen.getByText('Refactoring Retreat').closest('[class*="cursor-pointer"]')!
|
||||
expect(resultTwo.className).toContain('bg-accent')
|
||||
})
|
||||
})
|
||||
@@ -330,9 +352,9 @@ describe('SearchPanel', () => {
|
||||
elapsed_ms: 30,
|
||||
})
|
||||
|
||||
// Spinner disappears after search completes
|
||||
// Spinner disappears after search completes — VaultEntry title shown instead of search result title
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -358,9 +380,9 @@ describe('SearchPanel', () => {
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
fireEvent.change(input, { target: { value: 'second' } })
|
||||
|
||||
// Only second query results should appear
|
||||
// Only second query results should appear — VaultEntry title shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Second Result')).toBeInTheDocument()
|
||||
expect(screen.getByText('Refactoring Retreat')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ function SearchContent({
|
||||
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
|
||||
{entry?.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{result.title}
|
||||
{entry?.title ?? result.title}
|
||||
</span>
|
||||
{noteType && (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span>
|
||||
|
||||
@@ -252,50 +252,18 @@ describe('Sidebar', () => {
|
||||
expect(screen.queryByText('Types')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entity names under their section groups after expanding', () => {
|
||||
it('does not show inline entity names — sections are flat rows', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Sections start collapsed by default — expand them first
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Responsibilities'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Experiments'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Procedures'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Grow Newsletter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Stock Screener')).toBeInTheDocument()
|
||||
expect(screen.getByText('Write Weekly Essays')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows People and Events items after expanding', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand People'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Events'))
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses and expands sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Start collapsed — items hidden
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
|
||||
// Expand
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
|
||||
// Collapse
|
||||
fireEvent.click(screen.getByLabelText('Collapse Projects'))
|
||||
// Individual entries should NOT appear inline in the sidebar
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Grow Newsletter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking an entity', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'entity',
|
||||
entry: mockEntries[0],
|
||||
})
|
||||
it('shows note count chip on type sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Projects section has 1 entry — count chip should be a sibling of the label
|
||||
const projectsHeader = screen.getByText('Projects').closest('[class*="group/section"]')!
|
||||
expect(projectsHeader.textContent).toContain('1')
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking a section header', () => {
|
||||
@@ -308,37 +276,12 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('expands a collapsed section when clicking its header', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Sections start collapsed — items hidden
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
// Click the section header text (not the chevron)
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
// Section should now be expanded
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses an expanded+selected section when clicking its header again', () => {
|
||||
const projectSelection: SidebarSelection = { kind: 'sectionGroup', type: 'Project' }
|
||||
render(<Sidebar entries={mockEntries} selection={projectSelection} onSelect={() => {}} />)
|
||||
// First click expands (starts collapsed) and selects
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
// Second click: section is expanded + selected → should collapse
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects but keeps expanded an unselected expanded section when clicking its header', () => {
|
||||
it('selects on every click — no expand/collapse toggle', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
// Expand via chevron first
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
// Click the header — section is expanded but not selected → should select and stay expanded
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Project' })
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(onSelect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('calls onSelect with sectionGroup for People', () => {
|
||||
@@ -351,101 +294,23 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders Topics section with topic entries after expanding', () => {
|
||||
it('renders Topics section header', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Topics')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByLabelText('Expand Topics'))
|
||||
expect(screen.getByText('Software Development')).toBeInTheDocument()
|
||||
expect(screen.getByText('Trading')).toBeInTheDocument()
|
||||
// Topic entries are NOT shown inline
|
||||
expect(screen.queryByText('Software Development')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with entity kind when clicking a topic', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Topics'))
|
||||
fireEvent.click(screen.getByText('Software Development'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'entity',
|
||||
entry: mockEntries[4],
|
||||
})
|
||||
})
|
||||
|
||||
it('renders + buttons for each section group when onCreateType is provided', () => {
|
||||
const onCreateType = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
||||
const createButtons = screen.getAllByTitle(/^New /)
|
||||
expect(createButtons.length).toBe(7) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics (no Type entries → no Types section)
|
||||
})
|
||||
|
||||
it('calls onCreateType with correct type when + button is clicked', () => {
|
||||
const onCreateType = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
||||
fireEvent.click(screen.getByTitle('New Project'))
|
||||
expect(onCreateType).toHaveBeenCalledWith('Project')
|
||||
})
|
||||
|
||||
it('does not render + buttons when onCreateType is not provided', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
it('does not render + buttons on type sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
|
||||
expect(screen.queryByTitle('New Project')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders commit button even when no modified files', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} onCommitPush={() => {}} />)
|
||||
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows badge on commit button when modified files exist', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} onCommitPush={() => {}} />)
|
||||
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
|
||||
const badges = screen.getAllByText('3')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows Changes nav item when modifiedCount > 0', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={5} />)
|
||||
expect(screen.getByText('Changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Changes nav item when modifiedCount is 0', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={0} />)
|
||||
it('does not render Changes or Pulse in sidebar', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Changes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with changes filter when clicking Changes', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={onSelect} modifiedCount={3} />)
|
||||
fireEvent.click(screen.getByText('Changes'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'changes' })
|
||||
})
|
||||
|
||||
describe('Changes and Pulse in secondary bottom area', () => {
|
||||
it('renders Changes outside the main top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
|
||||
const changesEl = screen.getByText('Changes')
|
||||
// Changes should be inside the secondary bottom area, not the top nav
|
||||
const secondaryArea = changesEl.closest('[data-testid="sidebar-secondary"]')
|
||||
expect(secondaryArea).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders Pulse outside the main top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} isGitVault />)
|
||||
const pulseEl = screen.getByText('Pulse')
|
||||
const secondaryArea = pulseEl.closest('[data-testid="sidebar-secondary"]')
|
||||
expect(secondaryArea).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not render Changes or Pulse inside the top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
expect(topNav.textContent).not.toContain('Changes')
|
||||
expect(topNav.textContent).not.toContain('Pulse')
|
||||
})
|
||||
|
||||
it('shows Changes badge count in secondary area', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={7} isGitVault />)
|
||||
const secondaryArea = screen.getByTestId('sidebar-secondary')
|
||||
expect(secondaryArea.textContent).toContain('7')
|
||||
})
|
||||
expect(screen.queryByText('Pulse')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Commit & Push')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('dynamic custom type sections', () => {
|
||||
@@ -569,24 +434,9 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows instances of custom types under their section after expanding', () => {
|
||||
it('does not show inline instances — sections are flat rows', () => {
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Recipes'))
|
||||
expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders + button on custom type sections for creating instances', () => {
|
||||
const onCreateType = vi.fn()
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
||||
fireEvent.click(screen.getByTitle('New Recipe'))
|
||||
expect(onCreateType).toHaveBeenCalledWith('Recipe')
|
||||
})
|
||||
|
||||
it('calls onCreateNewType when + is clicked on Types section', () => {
|
||||
const onCreateNewType = vi.fn()
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateNewType={onCreateNewType} />)
|
||||
fireEvent.click(screen.getByTitle('New Type'))
|
||||
expect(onCreateNewType).toHaveBeenCalled()
|
||||
expect(screen.queryByText('Pasta Carbonara')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show section for type with zero active entries', () => {
|
||||
@@ -978,11 +828,10 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('includes both explicit and untyped notes under Notes section', () => {
|
||||
it('counts both explicit and untyped notes in Notes section chip', () => {
|
||||
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Notes'))
|
||||
expect(screen.getByText('Explicit Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('Untyped Note')).toBeInTheDocument()
|
||||
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('2')
|
||||
})
|
||||
|
||||
it('shows Notes section for untyped entries even without explicit Note entries', () => {
|
||||
@@ -1059,16 +908,8 @@ describe('Sidebar', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
|
||||
})
|
||||
|
||||
describe('emoji icon in sidebar section children', () => {
|
||||
it('does not show inline entries — no child items in type sections', () => {
|
||||
const entriesWithEmoji: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/project.md', filename: 'project.md', title: 'Project', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: 'rocket-launch', color: 'purple', order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/project/build-app.md', filename: 'build-app.md', title: 'Build App',
|
||||
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
@@ -1077,30 +918,44 @@ describe('Sidebar', () => {
|
||||
icon: '🚀', color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/project/no-icon.md', filename: 'no-icon.md', title: 'No Icon Project',
|
||||
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Build App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows emoji icon before title in expanded section child', () => {
|
||||
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
const buildApp = screen.getByText('Build App')
|
||||
const parent = buildApp.closest('div')!
|
||||
expect(parent.textContent).toBe('🚀Build App')
|
||||
describe('FAVORITES section', () => {
|
||||
const favEntry: VaultEntry = {
|
||||
path: '/vault/project/fav.md', filename: 'fav.md', title: 'My Favorite Note',
|
||||
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 100, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
favorite: true, favoriteIndex: 0,
|
||||
}
|
||||
|
||||
it('shows FAVORITES section when there are favorites', () => {
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('FAVORITES')).toBeInTheDocument()
|
||||
expect(screen.getByText('My Favorite Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show emoji for notes without icon', () => {
|
||||
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
const noIcon = screen.getByText('No Icon Project')
|
||||
const parent = noIcon.closest('div')!
|
||||
expect(parent.textContent).toBe('No Icon Project')
|
||||
it('hides FAVORITES section when no favorites', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides trashed favorites from the section', () => {
|
||||
const trashedFav = { ...favEntry, trashed: true }
|
||||
render(<Sidebar entries={[...mockEntries, trashedFav]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with favorites filter when clicking a favorite', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByText('My Favorite Note'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import type { VaultEntry, FolderNode, SidebarSelection, ViewFile } from '../types'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { buildDynamicSections, sortSections } from '../utils/sidebarSections'
|
||||
import { TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
@@ -12,14 +12,17 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse, Tray,
|
||||
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { arrayMove } from '@dnd-kit/sortable'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
type SectionGroup, isSelectionActive,
|
||||
NavItem, SectionContent, type SectionContentProps, VisibilityPopover,
|
||||
} from './SidebarParts'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import { FolderTree } from './FolderTree'
|
||||
|
||||
interface SidebarProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -33,11 +36,15 @@ interface SidebarProps {
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
onToggleTypeVisibility?: (typeName: string) => void
|
||||
modifiedCount?: number
|
||||
onSelectFavorite?: (entry: VaultEntry) => void
|
||||
onReorderFavorites?: (orderedPaths: string[]) => void
|
||||
views?: ViewFile[]
|
||||
onCreateView?: () => void
|
||||
onDeleteView?: (filename: string) => void
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => void
|
||||
inboxCount?: number
|
||||
onCommitPush?: () => void
|
||||
onCollapse?: () => void
|
||||
isGitVault?: boolean
|
||||
}
|
||||
|
||||
// --- Hooks ---
|
||||
@@ -112,24 +119,21 @@ function applyCustomization(
|
||||
|
||||
function SortableSection({ group, sectionProps }: {
|
||||
group: SectionGroup
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'onToggle' | 'isRenaming' | 'renameInitialValue'>
|
||||
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string }
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'itemCount' | 'isRenaming' | 'renameInitialValue'>
|
||||
& { entries: VaultEntry[]; renamingType: string | null; renameInitialValue: string }
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const items = sectionProps.entries.filter((e) =>
|
||||
const itemCount = sectionProps.entries.filter((e) =>
|
||||
!e.archived && !e.trashed && (group.type === 'Note' ? (e.isA === 'Note' || !e.isA) : e.isA === group.type),
|
||||
)
|
||||
const isCollapsed = sectionProps.collapsed[group.type] ?? true
|
||||
).length
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '4px 6px' }} {...attributes}>
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '0 6px' }} {...attributes}>
|
||||
<SectionContent
|
||||
group={group} items={items} isCollapsed={isCollapsed}
|
||||
group={group} itemCount={itemCount}
|
||||
selection={sectionProps.selection} onSelect={sectionProps.onSelect}
|
||||
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
|
||||
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
|
||||
onToggle={() => sectionProps.onToggle(group.type)}
|
||||
onContextMenu={sectionProps.onContextMenu}
|
||||
dragHandleProps={listeners}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
|
||||
@@ -140,17 +144,94 @@ function SortableSection({ group, sectionProps }: {
|
||||
)
|
||||
}
|
||||
|
||||
function CommitButton({ modifiedCount, onClick }: { modifiedCount: number; onClick?: () => void }) {
|
||||
if (!onClick) return null
|
||||
function SortableFavoriteItem({ entry, isActive, onSelect }: {
|
||||
entry: VaultEntry; isActive: boolean; onSelect: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.path })
|
||||
const icon = entry.icon && isEmoji(entry.icon) ? entry.icon : null
|
||||
return (
|
||||
<div className="shrink-0 border-t border-border" style={{ padding: 12 }}>
|
||||
<button className="flex w-full items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" style={{ borderRadius: 6, gap: 6, padding: '8px 16px', border: 'none', cursor: 'pointer' }} onClick={onClick}>
|
||||
<GitCommitHorizontal size={14} />
|
||||
<span className="text-[13px] font-medium">Commit & Push</span>
|
||||
{modifiedCount > 0 && (
|
||||
<span className="text-white font-semibold" style={{ background: '#ffffff40', borderRadius: 9, padding: '0 6px', fontSize: 10 }}>{modifiedCount}</span>
|
||||
)}
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }} {...attributes} {...listeners}>
|
||||
<div
|
||||
className={`flex cursor-pointer select-none items-center gap-1.5 rounded text-[13px] font-normal transition-colors ${isActive ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-accent'}`}
|
||||
style={{ padding: '4px 16px 4px 28px' }}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{icon && <span className="shrink-0">{icon}</span>}
|
||||
<span className="truncate">{entry.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorder }: {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onSelectNote?: (entry: VaultEntry) => void
|
||||
onReorder?: (orderedPaths: string[]) => void
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const favorites = useMemo(
|
||||
() => entries
|
||||
.filter((e) => e.favorite && !e.archived && !e.trashed)
|
||||
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity)),
|
||||
[entries],
|
||||
)
|
||||
const favIds = useMemo(() => favorites.map((f) => f.path), [favorites])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIndex = favIds.indexOf(active.id as string)
|
||||
const newIndex = favIds.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
const reordered = arrayMove(favIds, oldIndex, newIndex)
|
||||
onReorder?.(reordered)
|
||||
}, [favIds, onReorder])
|
||||
|
||||
if (favorites.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 6px 0' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>FAVORITES</span>
|
||||
</div>
|
||||
<span className="flex items-center justify-center text-muted-foreground" style={{ height: 18, borderRadius: 9999, padding: '0 5px', fontSize: 10, background: 'var(--muted)' }}>
|
||||
{favorites.length}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={favIds} strategy={verticalListSortingStrategy}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{favorites.map((entry) => {
|
||||
const isActive = isSelectionActive(selection, { kind: 'entity', entry })
|
||||
return (
|
||||
<SortableFavoriteItem
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
isActive={isActive}
|
||||
onSelect={() => {
|
||||
onSelect({ kind: 'filter', filter: 'favorites' })
|
||||
onSelectNote?.(entry)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -220,12 +301,12 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
|
||||
// --- Main Sidebar ---
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
entries, selection, onSelect,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility,
|
||||
modifiedCount = 0, inboxCount = 0, onCommitPush, onCollapse, isGitVault = false,
|
||||
onToggleTypeVisibility, onSelectFavorite, onReorderFavorites,
|
||||
views = [], onCreateView, onDeleteView,
|
||||
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [renamingType, setRenamingType] = useState<string | null>(null)
|
||||
@@ -251,10 +332,6 @@ export const Sidebar = memo(function Sidebar({
|
||||
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
|
||||
useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget)
|
||||
|
||||
const toggleSection = useCallback((type: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [type]: !(prev[type] ?? true) }))
|
||||
}, [])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -295,8 +372,8 @@ export const Sidebar = memo(function Sidebar({
|
||||
}, [customizeTarget, onUpdateTypeTemplate])
|
||||
|
||||
const sectionProps = {
|
||||
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onContextMenu: handleContextMenu, onToggle: toggleSection,
|
||||
entries, selection, onSelect,
|
||||
onContextMenu: handleContextMenu,
|
||||
renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename,
|
||||
}
|
||||
|
||||
@@ -306,12 +383,49 @@ export const Sidebar = memo(function Sidebar({
|
||||
<nav className="flex-1 overflow-y-auto">
|
||||
{/* Top nav */}
|
||||
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
|
||||
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
|
||||
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
|
||||
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-destructive text-destructive-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
</div>
|
||||
|
||||
{/* Favorites */}
|
||||
<FavoritesSection entries={entries} selection={selection} onSelect={onSelect} onSelectNote={onSelectFavorite} onReorder={onReorderFavorites} />
|
||||
|
||||
{/* Views */}
|
||||
{(views.length > 0 || onCreateView) && (
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">Views</span>
|
||||
{onCreateView && (
|
||||
<button className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground" style={{ width: 20, height: 20 }} onClick={onCreateView} aria-label="Create view" title="Create view">
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{views.map((v) => (
|
||||
<div key={v.filename} className="group relative">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
label={v.definition.name}
|
||||
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
|
||||
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
compact
|
||||
/>
|
||||
{onDeleteView && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
|
||||
title="Delete view"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px 0' }}>
|
||||
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
|
||||
@@ -331,16 +445,11 @@ export const Sidebar = memo(function Sidebar({
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Folder tree */}
|
||||
<FolderTree folders={folders} selection={selection} onSelect={onSelect} onCreateFolder={onCreateFolder} />
|
||||
</nav>
|
||||
|
||||
{/* Secondary area: Changes + Pulse */}
|
||||
<div className="shrink-0 border-t border-border" data-testid="sidebar-secondary" style={{ padding: '4px 6px' }}>
|
||||
{modifiedCount > 0 && (
|
||||
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} compact />
|
||||
)}
|
||||
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} compact />
|
||||
</div>
|
||||
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
|
||||
</aside>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type ComponentType, useState, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import type { SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { getTypeColor } from '../utils/typeColors'
|
||||
import { type IconProps } from '@phosphor-icons/react'
|
||||
|
||||
export interface SectionGroup {
|
||||
@@ -19,14 +17,16 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
|
||||
switch (check.kind) {
|
||||
case 'filter': return (current as typeof check).filter === check.filter
|
||||
case 'sectionGroup': return (current as typeof check).type === check.type
|
||||
case 'folder': return (current as typeof check).path === check.path
|
||||
case 'entity': return (current as typeof check).entry.path === check.entry.path
|
||||
case 'view': return (current as typeof check).filename === check.filename
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
// --- NavItem ---
|
||||
|
||||
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled, disabledTooltip, compact }: {
|
||||
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
|
||||
icon: ComponentType<IconProps>
|
||||
label: string
|
||||
count?: number
|
||||
@@ -34,6 +34,8 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
activeClassName?: string
|
||||
badgeClassName?: string
|
||||
badgeStyle?: React.CSSProperties
|
||||
activeBadgeClassName?: string
|
||||
activeBadgeStyle?: React.CSSProperties
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string
|
||||
@@ -42,6 +44,8 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
const iconSize = compact ? 14 : 16
|
||||
const textClass = compact ? 'text-[12px]' : 'text-[13px]'
|
||||
const padding = compact ? '4px 16px' : '6px 16px'
|
||||
const resolvedBadgeClass = isActive && activeBadgeClassName ? activeBadgeClassName : badgeClassName
|
||||
const resolvedBadgeStyle = isActive && activeBadgeClassName ? activeBadgeStyle : badgeStyle
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
@@ -57,10 +61,10 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
style={{ padding, borderRadius: 4 }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={iconSize} />
|
||||
<Icon size={iconSize} weight={isActive ? 'fill' : 'regular'} />
|
||||
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className={cn("flex items-center justify-center", badgeClassName)} style={{ height: compact ? 18 : 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...badgeStyle }}>
|
||||
<span className={cn("flex items-center justify-center", resolvedBadgeClass)} style={{ height: compact ? 18 : 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...resolvedBadgeStyle }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
@@ -72,15 +76,10 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
|
||||
export interface SectionContentProps {
|
||||
group: SectionGroup
|
||||
items: VaultEntry[]
|
||||
isCollapsed: boolean
|
||||
itemCount: number
|
||||
selection: SidebarSelection
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onSelectNote?: (entry: VaultEntry) => void
|
||||
onCreateType?: (type: string) => void
|
||||
onCreateNewType?: () => void
|
||||
onContextMenu: (e: React.MouseEvent, type: string) => void
|
||||
onToggle: () => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean
|
||||
renameInitialValue?: string
|
||||
@@ -88,75 +87,28 @@ export interface SectionContentProps {
|
||||
onRenameCancel?: () => void
|
||||
}
|
||||
|
||||
function childSelection(entry: VaultEntry): SidebarSelection {
|
||||
return { kind: 'entity', entry }
|
||||
}
|
||||
|
||||
function resolveCreateHandler(type: string, onCreateType?: (type: string) => void, onCreateNewType?: () => void): (() => void) | undefined {
|
||||
const isType = type === 'Type'
|
||||
if (!onCreateType && !(isType && onCreateNewType)) return undefined
|
||||
return isType ? () => onCreateNewType?.() : () => onCreateType?.(type)
|
||||
}
|
||||
|
||||
export function SectionContent({
|
||||
group, items, isCollapsed, selection, onSelect, onSelectNote,
|
||||
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
|
||||
group, itemCount, selection, onSelect,
|
||||
onContextMenu, dragHandleProps,
|
||||
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
|
||||
}: SectionContentProps) {
|
||||
const { label, type, Icon, customColor } = group
|
||||
const sectionColor = getTypeColor(type, customColor)
|
||||
const sectionLightColor = getTypeLightColor(type, customColor)
|
||||
const onCreate = resolveCreateHandler(type, onCreateType, onCreateNewType)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
label={label} type={type} Icon={Icon}
|
||||
sectionColor={sectionColor}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={isSelectionActive(selection, { kind: 'sectionGroup', type })}
|
||||
showCreate={!!onCreate}
|
||||
onSelect={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
onContextMenu={(e) => onContextMenu(e, type)}
|
||||
onToggle={onToggle}
|
||||
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={renameInitialValue}
|
||||
onRenameSubmit={onRenameSubmit}
|
||||
onRenameCancel={onRenameCancel}
|
||||
/>
|
||||
{!isCollapsed && items.length > 0 && (
|
||||
<SectionChildList
|
||||
items={items} selection={selection}
|
||||
sectionColor={sectionColor} sectionLightColor={sectionLightColor}
|
||||
onSelect={onSelect} onSelectNote={onSelectNote}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildList({ items, selection, sectionColor, sectionLightColor, onSelect, onSelectNote }: {
|
||||
items: VaultEntry[]; selection: SidebarSelection
|
||||
sectionColor: string; sectionLightColor: string
|
||||
onSelect: (sel: SidebarSelection) => void; onSelectNote?: (entry: VaultEntry) => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{items.map((entry) => {
|
||||
const sel = childSelection(entry)
|
||||
const active = isSelectionActive(selection, sel)
|
||||
return (
|
||||
<SectionChildItem
|
||||
key={entry.path} title={entry.title} icon={entry.icon} isActive={active}
|
||||
sectionColor={active ? sectionColor : undefined}
|
||||
sectionLightColor={active ? sectionLightColor : undefined}
|
||||
onClick={() => { onSelect(sel); onSelectNote?.(entry) }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<SectionHeader
|
||||
label={label} type={type} Icon={Icon}
|
||||
sectionColor={sectionColor}
|
||||
itemCount={itemCount}
|
||||
isActive={isSelectionActive(selection, { kind: 'sectionGroup', type })}
|
||||
onSelect={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
onContextMenu={(e) => onContextMenu(e, type)}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={renameInitialValue}
|
||||
onRenameSubmit={onRenameSubmit}
|
||||
onRenameCancel={onRenameCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,11 +142,10 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
function SectionHeader({ label, type, Icon, sectionColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
label: string; type: string; Icon: ComponentType<IconProps>
|
||||
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
|
||||
sectionColor: string; itemCount: number; isActive: boolean
|
||||
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
|
||||
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean; renameInitialValue?: string
|
||||
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
|
||||
@@ -204,12 +155,8 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
|
||||
{...dragHandleProps}
|
||||
onClick={() => {
|
||||
if (isRenaming) return
|
||||
if (isCollapsed) { onToggle(); onSelect() }
|
||||
else if (isActive) { onToggle() }
|
||||
else { onSelect() }
|
||||
}} onContextMenu={isRenaming ? undefined : onContextMenu}
|
||||
onClick={() => { if (!isRenaming) onSelect() }}
|
||||
onContextMenu={isRenaming ? undefined : onContextMenu}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
|
||||
<Icon size={16} style={{ color: sectionColor, flexShrink: 0 }} />
|
||||
@@ -224,32 +171,11 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
|
||||
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: 2 }}>
|
||||
{showCreate && (
|
||||
<button className="flex shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/section:opacity-100 cursor-pointer" style={{ width: 20, height: 20 }} onClick={onCreate} aria-label={type === 'Type' ? 'Create new Type' : `Create new ${type}`} title={type === 'Type' ? 'New Type' : `New ${type}`}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button className="flex shrink-0 items-center border-none bg-transparent p-0 text-inherit cursor-pointer" onClick={(e) => { e.stopPropagation(); onToggle() }} aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildItem({ title, icon, isActive, sectionColor, sectionLightColor, onClick }: {
|
||||
title: string; icon?: string | null; isActive: boolean
|
||||
sectionColor?: string; sectionLightColor?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("cursor-pointer truncate rounded-md text-[13px] font-normal transition-colors", isActive ? "text-foreground" : "text-muted-foreground hover:bg-accent")}
|
||||
style={{ padding: '4px 16px 4px 28px', ...(isActive && { backgroundColor: sectionLightColor, color: sectionColor }) }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && isEmoji(icon) && <span className="mr-1">{icon}</span>}{title}
|
||||
{itemCount > 0 && (
|
||||
<span className="flex items-center justify-center text-muted-foreground" style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}>
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,16 +92,16 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
|
||||
if (query.length < MIN_QUERY_LENGTH) return []
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const items = attachClickHandlers(candidates, insertWikilink)
|
||||
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
|
||||
return enrichSuggestionItems(items, query, typeEntryMap)
|
||||
}, [baseItems, insertWikilink, typeEntryMap])
|
||||
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
|
||||
|
||||
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
|
||||
if (query.length < PERSON_MENTION_MIN_QUERY) return []
|
||||
const candidates = filterPersonMentions(baseItems, query)
|
||||
const items = attachClickHandlers(candidates, insertWikilink)
|
||||
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
|
||||
return enrichSuggestionItems(items, query, typeEntryMap)
|
||||
}, [baseItems, insertWikilink, typeEntryMap])
|
||||
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>
|
||||
|
||||
@@ -182,18 +182,19 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByText('Connect GitHub repo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows modified count when modifiedCount is > 0', () => {
|
||||
it('shows Changes badge with count when modifiedCount is > 0', () => {
|
||||
render(<StatusBar noteCount={100} modifiedCount={3} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.getByTestId('status-modified-count')).toBeInTheDocument()
|
||||
expect(screen.getByText('3 pending')).toBeInTheDocument()
|
||||
expect(screen.getByText('Changes')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show modified count when modifiedCount is 0', () => {
|
||||
it('does not show Changes badge when modifiedCount is 0', () => {
|
||||
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show modified count when modifiedCount is not provided', () => {
|
||||
it('does not show Changes badge when modifiedCount is not provided', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.queryByTestId('status-modified-count')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -313,4 +314,42 @@ describe('StatusBar', () => {
|
||||
expect(screen.getByText(/1 behind/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Pulse badge in status bar', () => {
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isGitVault />)
|
||||
expect(screen.getByTestId('status-pulse')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pulse')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClickPulse when clicking Pulse badge', () => {
|
||||
const onClickPulse = vi.fn()
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isGitVault onClickPulse={onClickPulse} />)
|
||||
fireEvent.click(screen.getByTestId('status-pulse'))
|
||||
expect(onClickPulse).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables Pulse badge when isGitVault is false', () => {
|
||||
const onClickPulse = vi.fn()
|
||||
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} isGitVault={false} onClickPulse={onClickPulse} />)
|
||||
fireEvent.click(screen.getByTestId('status-pulse'))
|
||||
expect(onClickPulse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows Commit button in status bar', () => {
|
||||
const onCommitPush = vi.fn()
|
||||
render(<StatusBar noteCount={100} modifiedCount={5} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={onCommitPush} />)
|
||||
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('status-commit-push'))
|
||||
expect(onCommitPush).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows Commit button even when no modified files', () => {
|
||||
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={vi.fn()} />)
|
||||
expect(screen.getByTestId('status-commit-push')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Commit button when no onCommitPush callback', () => {
|
||||
render(<StatusBar noteCount={100} modifiedCount={5} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
|
||||
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
|
||||
import { GitDiff, Pulse } from '@phosphor-icons/react'
|
||||
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
@@ -20,6 +21,9 @@ interface StatusBarProps {
|
||||
onOpenLocalFolder?: () => void
|
||||
onConnectGitHub?: () => void
|
||||
onClickPending?: () => void
|
||||
onClickPulse?: () => void
|
||||
onCommitPush?: () => void
|
||||
isGitVault?: boolean
|
||||
hasGitHub?: boolean
|
||||
syncStatus?: SyncStatus
|
||||
lastSyncTime?: number | null
|
||||
@@ -328,7 +332,7 @@ function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
|
||||
function ChangesBadge({ count, onClick }: { count: number; onClick?: () => void }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<>
|
||||
@@ -341,7 +345,58 @@ function PendingBadge({ count, onClick }: { count: number; onClick?: () => void
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-modified-count"
|
||||
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{count} pending</span>
|
||||
>
|
||||
<GitDiff size={13} style={{ color: 'var(--accent-orange)' }} />
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: 'var(--accent-orange)', color: '#fff', borderRadius: 9, padding: '0 5px', fontSize: 10, fontWeight: 600, minWidth: 16, lineHeight: '16px' }}>{count}</span>
|
||||
Changes
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CommitButton({ onClick }: { onClick?: () => void }) {
|
||||
if (!onClick) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Commit & Push"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-commit-push"
|
||||
>
|
||||
<GitCommitHorizontal size={13} />
|
||||
Commit
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PulseBadge({ onClick, disabled }: { onClick?: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={disabled ? undefined : 'button'}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
style={{
|
||||
...ICON_STYLE,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
padding: '2px 4px',
|
||||
borderRadius: 3,
|
||||
background: 'transparent',
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
}}
|
||||
title={disabled ? 'Pulse is only available for git-enabled vaults' : 'View pulse'}
|
||||
onMouseEnter={disabled ? undefined : (e) => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={disabled ? undefined : (e) => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-pulse"
|
||||
>
|
||||
<Pulse size={13} />Pulse
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -381,7 +436,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, onClickPulse, onCommitPush, isGitVault = false, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
@@ -389,8 +444,8 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)', position: 'relative', zIndex: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0 }}>
|
||||
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} onRemoveVault={onRemoveVault} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
@@ -402,14 +457,15 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
onMouseEnter={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} />
|
||||
<CommitButton onClick={onCommitPush} />
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} remoteStatus={remoteStatus} onTriggerSync={onTriggerSync} onPullAndPush={onPullAndPush} onOpenConflictResolver={onOpenConflictResolver} />
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
<PulseBadge onClick={onClickPulse} disabled={!isGitVault} />
|
||||
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
|
||||
{zoomLevel !== 100 && (
|
||||
<span
|
||||
|
||||
@@ -101,4 +101,34 @@ describe('TitleField', () => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('resets stale localValue when title prop changes after focus', () => {
|
||||
// Regression: creating a new note fires focus-editor before React re-renders,
|
||||
// so handleFocus captures the OLD note's title into localValue.
|
||||
// When React re-renders with the new title, localValue should be cleared.
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<TitleField title="Old Note" filename="old-note.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
// Simulate: focus fires while title prop is still "Old Note"
|
||||
fireEvent.focus(input)
|
||||
expect(input).toHaveValue('Old Note')
|
||||
// React re-renders with new note's title (tab switched)
|
||||
rerender(<TitleField title="Untitled note" filename="untitled-note.md" onTitleChange={onChange} />)
|
||||
expect(input).toHaveValue('Untitled note')
|
||||
})
|
||||
|
||||
it('shows vault-relative path for notes in subdirectories', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack.md')
|
||||
})
|
||||
|
||||
it('hides path for notes at vault root', () => {
|
||||
render(<TitleField title="Root Note" filename="root-note.md" notePath="/Users/luca/Laputa/root-note.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides path when vaultPath is not provided', () => {
|
||||
render(<TitleField title="Note" filename="note.md" onTitleChange={() => {}} />)
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,10 @@ interface TitleFieldProps {
|
||||
title: string
|
||||
filename: string
|
||||
editable?: boolean
|
||||
/** Absolute path of the note file. */
|
||||
notePath?: string
|
||||
/** Absolute path of the vault root. */
|
||||
vaultPath?: string
|
||||
/** Called when the user finishes editing the title (blur or Enter). */
|
||||
onTitleChange: (newTitle: string) => void
|
||||
}
|
||||
@@ -15,6 +19,15 @@ function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
|
||||
// [optimisticTitle, forPropTitle]: shown after commit until title prop catches up
|
||||
const [optimistic, setOptimistic] = useState<[string, string] | null>(null)
|
||||
const isFocusedRef = useRef(false)
|
||||
const [prevTitle, setPrevTitle] = useState(title)
|
||||
|
||||
// Reset local edit when the title prop changes (e.g. note switch).
|
||||
// This prevents a stale handleFocus closure from locking in the old note's title
|
||||
// when focus-editor fires before React re-renders with the new tab.
|
||||
if (prevTitle !== title) {
|
||||
setPrevTitle(title)
|
||||
if (localValue !== null) setLocalValue(null)
|
||||
}
|
||||
|
||||
// Clear optimistic once the prop changes (rename completed or tab switched)
|
||||
const optimisticValue = optimistic && optimistic[1] === title ? optimistic[0] : null
|
||||
@@ -48,7 +61,7 @@ function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
|
||||
* Dedicated title input field above the editor.
|
||||
* Displays the title as an editable field and shows the resulting filename below.
|
||||
*/
|
||||
export function TitleField({ title, filename, editable = true, onTitleChange }: TitleFieldProps) {
|
||||
export function TitleField({ title, filename, editable = true, notePath, vaultPath, onTitleChange }: TitleFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
|
||||
useOptimisticTitle(title, onTitleChange)
|
||||
@@ -80,6 +93,12 @@ export function TitleField({ title, filename, editable = true, onTitleChange }:
|
||||
const currentStem = filename.replace(/\.md$/, '')
|
||||
const showFilename = isEditing || currentStem !== expectedSlug
|
||||
|
||||
// Compute vault-relative path (only for notes in subdirectories)
|
||||
const relativePath = notePath && vaultPath
|
||||
? notePath.replace(vaultPath + '/', '')
|
||||
: null
|
||||
const showRelativePath = relativePath && relativePath.includes('/')
|
||||
|
||||
return (
|
||||
<div className="title-field" data-testid="title-field">
|
||||
<input
|
||||
@@ -100,6 +119,11 @@ export function TitleField({ title, filename, editable = true, onTitleChange }:
|
||||
{expectedSlug}.md
|
||||
</span>
|
||||
)}
|
||||
{showRelativePath && (
|
||||
<span className="title-field__path" data-testid="title-field-path" style={{ display: 'block', fontSize: 11, color: 'var(--muted-foreground)', marginTop: 2 }}>
|
||||
{relativePath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
|
||||
{onNavigate ? (
|
||||
<button
|
||||
className="min-w-0 max-w-full truncate border-none cursor-pointer ring-inset hover:ring-1 hover:ring-current"
|
||||
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
|
||||
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '0 8px', fontSize: 12, fontWeight: 500, height: 24, display: 'inline-flex', alignItems: 'center' }}
|
||||
onClick={() => onNavigate(isA.toLowerCase())} title={isA}
|
||||
>{isA}</button>
|
||||
) : (
|
||||
@@ -68,7 +68,8 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
|
||||
background: typeLightColor ?? undefined,
|
||||
color: typeColor ?? undefined,
|
||||
borderRadius: 6,
|
||||
padding: '4px 8px',
|
||||
padding: '0 8px',
|
||||
height: 24,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
|
||||
@@ -5,6 +5,7 @@ interface FilterPillsProps {
|
||||
active: NoteListFilter
|
||||
counts: Record<NoteListFilter, number>
|
||||
onChange: (filter: NoteListFilter) => void
|
||||
position?: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
const PILLS: { value: NoteListFilter; label: string }[] = [
|
||||
@@ -13,9 +14,18 @@ const PILLS: { value: NoteListFilter; label: string }[] = [
|
||||
{ value: 'trashed', label: 'Trashed' },
|
||||
]
|
||||
|
||||
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
|
||||
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)'
|
||||
|
||||
function FilterPillsInner({ active, counts, onChange, position = 'top' }: FilterPillsProps) {
|
||||
const isBottom = position === 'bottom'
|
||||
return (
|
||||
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="filter-pills">
|
||||
<div
|
||||
className={isBottom
|
||||
? 'absolute bottom-0 left-0 right-0 z-10 flex flex-wrap items-center justify-center gap-2 px-4 py-3'
|
||||
: 'flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5'}
|
||||
style={isBottom ? { background: BOTTOM_GRADIENT } : undefined}
|
||||
data-testid="filter-pills"
|
||||
>
|
||||
{PILLS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
|
||||
@@ -5,6 +5,7 @@ interface InboxFilterPillsProps {
|
||||
active: InboxPeriod
|
||||
counts: Record<InboxPeriod, number>
|
||||
onChange: (period: InboxPeriod) => void
|
||||
position?: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
@@ -14,9 +15,18 @@ const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {
|
||||
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)'
|
||||
|
||||
function InboxFilterPillsInner({ active, counts, onChange, position = 'top' }: InboxFilterPillsProps) {
|
||||
const isBottom = position === 'bottom'
|
||||
return (
|
||||
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="inbox-filter-pills">
|
||||
<div
|
||||
className={isBottom
|
||||
? 'absolute bottom-0 left-0 right-0 z-10 flex flex-wrap items-center justify-center gap-2 px-4 py-3'
|
||||
: 'flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5'}
|
||||
style={isBottom ? { background: BOTTOM_GRADIENT } : undefined}
|
||||
data-testid="inbox-filter-pills"
|
||||
>
|
||||
{PILLS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../../types'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
|
||||
import {
|
||||
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
|
||||
getSortComparator, extractSortableProperties,
|
||||
@@ -20,7 +20,7 @@ export function useTypeEntryMap(entries: VaultEntry[]) {
|
||||
|
||||
// --- useFilteredEntries ---
|
||||
|
||||
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod) {
|
||||
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod, views?: ViewFile[]) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
@@ -28,8 +28,8 @@ export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSele
|
||||
if (isEntityView) return []
|
||||
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
|
||||
if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month')
|
||||
return filterEntries(entries, selection, subFilter)
|
||||
}, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod])
|
||||
return filterEntries(entries, selection, subFilter, views)
|
||||
}, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views])
|
||||
}
|
||||
|
||||
// --- useNoteListData ---
|
||||
@@ -40,14 +40,15 @@ interface NoteListDataParams {
|
||||
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
|
||||
subFilter?: NoteListFilter
|
||||
inboxPeriod?: InboxPeriod
|
||||
views?: ViewFile[]
|
||||
}
|
||||
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod }: NoteListDataParams) {
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views }: NoteListDataParams) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isTrashView = (selection.kind === 'filter' && selection.filter === 'trash') || subFilter === 'trashed'
|
||||
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
|
||||
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod)
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views)
|
||||
|
||||
const searched = useMemo(() => {
|
||||
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../../types'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
|
||||
import type { RelationshipGroup } from '../../utils/noteListHelpers'
|
||||
|
||||
export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null): string {
|
||||
export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null, views?: ViewFile[]): string {
|
||||
if (selection.kind === 'view') {
|
||||
const view = views?.find((v) => v.filename === selection.filename)
|
||||
return view?.definition.name ?? 'View'
|
||||
}
|
||||
if (selection.kind === 'entity') return selection.entry.title
|
||||
if (typeDocument) return typeDocument.title
|
||||
if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive'
|
||||
|
||||
@@ -39,22 +39,6 @@ describe('frontmatterHighlightPlugin', () => {
|
||||
parent.remove()
|
||||
})
|
||||
|
||||
it('applies heading class to markdown headings', () => {
|
||||
const { view, parent } = createView('# Heading One\n\nSome text\n\n## Heading Two')
|
||||
const headings = parent.querySelectorAll('.cm-md-heading')
|
||||
expect(headings.length).toBeGreaterThanOrEqual(2)
|
||||
view.destroy()
|
||||
parent.remove()
|
||||
})
|
||||
|
||||
it('does not apply heading class to plain text', () => {
|
||||
const { view, parent } = createView('Just some plain text\nAnother line')
|
||||
const headings = parent.querySelectorAll('.cm-md-heading')
|
||||
expect(headings.length).toBe(0)
|
||||
view.destroy()
|
||||
parent.remove()
|
||||
})
|
||||
|
||||
it('handles content without frontmatter', () => {
|
||||
const { view, parent } = createView('# Just a heading\n\nNo frontmatter here.')
|
||||
const delimiters = parent.querySelectorAll('.cm-frontmatter-delimiter')
|
||||
|
||||
@@ -4,7 +4,6 @@ import { RangeSetBuilder } from '@codemirror/state'
|
||||
const frontmatterDelimiter = Decoration.mark({ class: 'cm-frontmatter-delimiter' })
|
||||
const frontmatterKey = Decoration.mark({ class: 'cm-frontmatter-key' })
|
||||
const frontmatterValue = Decoration.mark({ class: 'cm-frontmatter-value' })
|
||||
const markdownHeading = Decoration.mark({ class: 'cm-md-heading' })
|
||||
|
||||
function findFrontmatterEnd(doc: { lines: number; line(n: number): { text: string } }): number {
|
||||
if (doc.lines < 1) return -1
|
||||
@@ -27,8 +26,6 @@ function buildDecorations(view: EditorView): DecorationSet {
|
||||
|
||||
if (i <= fmEnd) {
|
||||
decorateFrontmatterLine(builder, line.from, text, i === 1 || i === fmEnd)
|
||||
} else {
|
||||
decorateMarkdownLine(builder, line.from, text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,16 +57,6 @@ function decorateFrontmatterLine(
|
||||
}
|
||||
}
|
||||
|
||||
function decorateMarkdownLine(
|
||||
builder: RangeSetBuilder<Decoration>,
|
||||
from: number,
|
||||
text: string,
|
||||
): void {
|
||||
if (/^#{1,6}\s/.test(text)) {
|
||||
builder.add(from, from + text.length, markdownHeading)
|
||||
}
|
||||
}
|
||||
|
||||
export const frontmatterHighlightPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
@@ -89,12 +76,9 @@ export function frontmatterHighlightTheme() {
|
||||
const keyColor = '#c9383e'
|
||||
const valueColor = '#2a7e4f'
|
||||
const delimiterColor = '#c9383e'
|
||||
const headingColor = '#0969da'
|
||||
|
||||
return EditorView.baseTheme({
|
||||
'.cm-frontmatter-delimiter': { color: delimiterColor, fontWeight: '600' },
|
||||
'.cm-frontmatter-key': { color: keyColor },
|
||||
'.cm-frontmatter-value': { color: valueColor },
|
||||
'.cm-md-heading': { color: headingColor, fontWeight: '600' },
|
||||
})
|
||||
}
|
||||
|
||||
51
src/extensions/markdownHighlight.test.ts
Normal file
51
src/extensions/markdownHighlight.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { markdownLanguage } from './markdownHighlight'
|
||||
|
||||
function createView(doc: string) {
|
||||
const parent = document.createElement('div')
|
||||
document.body.appendChild(parent)
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
extensions: [markdownLanguage()],
|
||||
})
|
||||
const view = new EditorView({ state, parent })
|
||||
return { view, parent }
|
||||
}
|
||||
|
||||
describe('markdownLanguage', () => {
|
||||
it('returns a valid extension', () => {
|
||||
const ext = markdownLanguage()
|
||||
expect(ext).toBeDefined()
|
||||
expect(Array.isArray(ext)).toBe(true)
|
||||
})
|
||||
|
||||
it('creates an editor without errors', () => {
|
||||
const { view, parent } = createView('# Heading\n\n**bold** and *italic*\n\n- list item')
|
||||
expect(view.state.doc.toString()).toContain('# Heading')
|
||||
view.destroy()
|
||||
parent.remove()
|
||||
})
|
||||
|
||||
it('parses markdown content with mixed syntax', () => {
|
||||
const doc = [
|
||||
'# Title',
|
||||
'',
|
||||
'Some **bold** and *italic* text.',
|
||||
'',
|
||||
'- item one',
|
||||
'- item two',
|
||||
'',
|
||||
'[a link](http://example.com)',
|
||||
'',
|
||||
'> a blockquote',
|
||||
'',
|
||||
'`inline code`',
|
||||
].join('\n')
|
||||
const { view, parent } = createView(doc)
|
||||
expect(view.state.doc.lines).toBe(12)
|
||||
view.destroy()
|
||||
parent.remove()
|
||||
})
|
||||
})
|
||||
28
src/extensions/markdownHighlight.ts
Normal file
28
src/extensions/markdownHighlight.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
|
||||
const markdownHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.heading1, color: '#0969da', fontWeight: '700', fontSize: '1.4em' },
|
||||
{ tag: tags.heading2, color: '#0969da', fontWeight: '700', fontSize: '1.25em' },
|
||||
{ tag: tags.heading3, color: '#0969da', fontWeight: '600', fontSize: '1.1em' },
|
||||
{ tag: tags.heading4, color: '#0969da', fontWeight: '600' },
|
||||
{ tag: tags.heading5, color: '#0969da', fontWeight: '600' },
|
||||
{ tag: tags.heading6, color: '#0969da', fontWeight: '600' },
|
||||
{ tag: tags.strong, fontWeight: '700' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
||||
{ tag: tags.link, color: '#0969da', textDecoration: 'underline' },
|
||||
{ tag: tags.url, color: '#0969da' },
|
||||
{ tag: tags.monospace, color: '#c9383e', backgroundColor: 'rgba(175,184,193,0.15)', borderRadius: '3px' },
|
||||
{ tag: tags.list, color: '#c9383e' },
|
||||
{ tag: tags.quote, color: '#636c76', fontStyle: 'italic' },
|
||||
{ tag: tags.separator, color: '#636c76' },
|
||||
{ tag: tags.processingInstruction, color: '#c9383e', fontWeight: '600' },
|
||||
{ tag: tags.contentSeparator, color: '#c9383e', fontWeight: '600' },
|
||||
])
|
||||
|
||||
export function markdownLanguage(): Extension {
|
||||
return [markdown(), syntaxHighlighting(markdownHighlightStyle)]
|
||||
}
|
||||
@@ -19,6 +19,8 @@ interface NoteCommandsConfig {
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
isFavorite?: boolean
|
||||
}
|
||||
|
||||
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
@@ -28,7 +30,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onEmptyTrash, trashedCount,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onOpenInNewWindow,
|
||||
onOpenInNewWindow, onToggleFavorite, isFavorite,
|
||||
} = config
|
||||
|
||||
return [
|
||||
@@ -47,6 +49,12 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'toggle-favorite', label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites', group: 'Note', shortcut: '⌘D',
|
||||
keywords: ['favorite', 'star', 'bookmark', 'pin'],
|
||||
enabled: hasActiveNote && !!onToggleFavorite,
|
||||
execute: () => { if (activeTabPath) onToggleFavorite?.(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
|
||||
|
||||
@@ -26,10 +26,10 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘⌥I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
|
||||
@@ -4,13 +4,17 @@ import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { updateMockFrontmatter, deleteMockFrontmatterProperty } from './mockFrontmatterHelpers'
|
||||
import { updateMockContent, trackMockChange } from '../mock-tauri'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
|
||||
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||
icon: { icon: null },
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
|
||||
_archived: { archived: false }, archived: { archived: false },
|
||||
_trashed: { trashed: false }, trashed: { trashed: false },
|
||||
order: { order: null },
|
||||
template: { template: null }, sort: { sort: null }, visible: { visible: null },
|
||||
_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },
|
||||
}
|
||||
|
||||
/** Check if a string contains a wikilink pattern `[[...]]`. */
|
||||
@@ -51,12 +55,15 @@ export function frontmatterToEntryPatch(
|
||||
type: { isA: str }, is_a: { isA: str }, status: { status: str }, color: { color: str },
|
||||
icon: { icon: str },
|
||||
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
|
||||
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
|
||||
_archived: { archived: Boolean(value) }, archived: { archived: Boolean(value) },
|
||||
_trashed: { trashed: Boolean(value) }, trashed: { trashed: Boolean(value) },
|
||||
order: { order: typeof value === 'number' ? value : null },
|
||||
template: { template: str },
|
||||
sort: { sort: str },
|
||||
view: { view: str },
|
||||
visible: { visible: value === false ? false : null },
|
||||
_favorite: { favorite: Boolean(value) },
|
||||
_favorite_index: { favoriteIndex: typeof value === 'number' ? value : null },
|
||||
}
|
||||
// Also update the relationships map for wikilink-containing values
|
||||
const wikilinks = value != null ? extractWikilinks(value) : []
|
||||
@@ -65,6 +72,17 @@ export function frontmatterToEntryPatch(
|
||||
return { patch: updates[k] ?? {}, relationshipPatch }
|
||||
}
|
||||
|
||||
/** Parse frontmatter from full content and return a merged VaultEntry patch for all known fields. */
|
||||
export function contentToEntryPatch(content: string): Partial<VaultEntry> {
|
||||
const fm = parseFrontmatter(content)
|
||||
const merged: Partial<VaultEntry> = {}
|
||||
for (const [key, value] of Object.entries(fm)) {
|
||||
const { patch } = frontmatterToEntryPatch('update', key, value)
|
||||
Object.assign(merged, patch)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
async function invokeFrontmatter(command: string, args: Record<string, unknown>): Promise<string> {
|
||||
return invoke<string>(command, args)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ interface AppCommandsConfig {
|
||||
noteListFilter?: NoteListFilter
|
||||
onSetNoteListFilter?: (filter: NoteListFilter) => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
@@ -111,6 +112,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onGoForward: config.onGoForward,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onToggleFavorite: config.onToggleFavorite,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user