Compare commits
75 Commits
v0.2026033
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
50
.claude/commands/laputa-done.md
Normal file
50
.claude/commands/laputa-done.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# /laputa-done <task_id>
|
||||
|
||||
Mark a Laputa task as done: add completion comment, move to In Review, notify Brian, 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. Notify Luca (informational only — no action needed from him)**
|
||||
|
||||
```bash
|
||||
openclaw system event --text "laputa-task-done:$ARGUMENTS" --mode now
|
||||
```
|
||||
|
||||
This is a passive notification. Luca reviews tasks in his own time. Do NOT wait for approval — proceed immediately to step 4.
|
||||
|
||||
**4. Pick the next task**
|
||||
|
||||
Run `/laputa-next-task` to get the next task and start working on it immediately.
|
||||
|
||||
If `/laputa-next-task` returns `NO_TASKS` → exit cleanly. The hourly watchdog will restart you when new tasks arrive.
|
||||
43
.claude/commands/laputa-next-task.md
Normal file
43
.claude/commands/laputa-next-task.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# /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 → output `NO_TASKS` and exit cleanly.
|
||||
2
.codescene-thresholds
Normal file
2
.codescene-thresholds
Normal file
@@ -0,0 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.6
|
||||
AVERAGE_THRESHOLD=9.37
|
||||
@@ -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.
|
||||
|
||||
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).
|
||||
@@ -86,3 +86,10 @@ 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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,6 @@
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::search::SearchResponse;
|
||||
use crate::vault::{RenameResult, VaultEntry};
|
||||
use crate::vault::{DetectedRename, FolderNode, RenameResult, VaultEntry};
|
||||
use crate::{frontmatter, git, search, vault};
|
||||
|
||||
use super::expand_tilde;
|
||||
@@ -13,6 +13,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 +43,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 +89,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]
|
||||
@@ -206,7 +226,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())
|
||||
@@ -341,7 +360,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 +369,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:"));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 8;
|
||||
const CACHE_VERSION: u32 = 9;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -284,20 +284,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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ mod trash;
|
||||
|
||||
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};
|
||||
|
||||
@@ -43,7 +45,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&content);
|
||||
let (frontmatter, mut relationships, 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);
|
||||
@@ -114,9 +116,12 @@ pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
|
||||
parse_md_file(path)
|
||||
}
|
||||
|
||||
/// 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_hidden_dir(name: &str) -> bool {
|
||||
name.starts_with('.') || HIDDEN_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
fn is_md_file(path: &Path) -> bool {
|
||||
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
|
||||
@@ -129,31 +134,25 @@ fn try_parse_md(path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
/// Scan all .md files in the vault, including subdirectories.
|
||||
/// Hidden directories (starting with `.`) are excluded.
|
||||
fn scan_all_md_files(vault_path: &Path, 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 is_md_file(entry.path()) {
|
||||
try_parse_md(entry.path(), entries);
|
||||
}
|
||||
}
|
||||
@@ -175,13 +174,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_md_files(vault_path, &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;
|
||||
|
||||
@@ -145,7 +145,7 @@ fn test_scan_vault_root_and_protected_folders() {
|
||||
}
|
||||
|
||||
#[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(
|
||||
@@ -160,8 +160,15 @@ fn test_scan_vault_skips_non_protected_subfolders() {
|
||||
);
|
||||
|
||||
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");
|
||||
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]
|
||||
@@ -1096,6 +1103,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).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")).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")).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 +1354,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::*;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,7 +39,7 @@
|
||||
|
||||
.app__editor {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-width: 800px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
||||
@@ -63,6 +63,7 @@ const mockAllContent: Record<string, string> = {
|
||||
|
||||
const mockCommandResults: Record<string, unknown> = {
|
||||
list_vault: mockEntries,
|
||||
list_vault_folders: [],
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
|
||||
150
src/App.tsx
150
src/App.tsx
@@ -25,6 +25,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 +47,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 +71,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 +79,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 +94,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 +130,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 +171,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 +254,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 +272,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.reloadVault()
|
||||
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 +300,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,
|
||||
@@ -243,7 +333,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()
|
||||
|
||||
@@ -350,18 +440,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 +488,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} 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} onCreateFolder={handleCreateFolder} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -419,6 +526,7 @@ function App() {
|
||||
onDeleteProperty={notes.handleDeleteProperty}
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
|
||||
onInitializeProperties={handleInitializeProperties}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
vaultPath={resolvedPath}
|
||||
@@ -450,25 +558,15 @@ 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} />
|
||||
<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,41 @@ 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 — 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,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
interface BreadcrumbBarProps {
|
||||
entry: VaultEntry
|
||||
wordCount: number
|
||||
noteStatus: NoteStatus
|
||||
showDiffToggle: boolean
|
||||
diffMode: boolean
|
||||
diffLoading: boolean
|
||||
@@ -34,6 +32,8 @@ interface BreadcrumbBarProps {
|
||||
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
|
||||
@@ -57,7 +57,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
rawMode, onToggleRaw,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onTrash, onRestore, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount' | 'noteStatus'>) {
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
|
||||
return (
|
||||
<div className="flex items-center" style={{ gap: 12 }}>
|
||||
<button
|
||||
@@ -146,7 +146,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 +163,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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,6 +41,7 @@ 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
|
||||
@@ -202,7 +203,7 @@ 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,
|
||||
@@ -286,6 +287,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
|
||||
@@ -91,7 +92,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 +100,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 +112,7 @@ function RawModeEditorSection({
|
||||
onContentChange={onContentChange ?? (() => {})}
|
||||
onSave={onSave ?? (() => {})}
|
||||
latestContentRef={latestContentRef}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -119,8 +122,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 +133,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}
|
||||
@@ -160,6 +164,7 @@ 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
|
||||
@@ -167,6 +172,24 @@ export function EditorContent({
|
||||
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,6 +203,7 @@ export function EditorContent({
|
||||
{activeTab && (
|
||||
<ActiveTabBreadcrumb
|
||||
activeTab={activeTab}
|
||||
barRef={breadcrumbBarRef}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
/>
|
||||
)}
|
||||
@@ -199,19 +223,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={rawMode} 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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -129,6 +129,9 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
{entry.title}
|
||||
<StateBadge archived={entry.archived} trashed={entry.trashed} />
|
||||
</div>
|
||||
{entry.path.includes('/') && (
|
||||
<div className="truncate text-[10px] text-muted-foreground" data-testid="note-path">{entry.path}</div>
|
||||
)}
|
||||
</div>
|
||||
{entry.snippet && (
|
||||
<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' }}>
|
||||
|
||||
@@ -867,7 +867,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}` })),
|
||||
|
||||
@@ -47,14 +47,15 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
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(
|
||||
@@ -102,9 +103,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
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">
|
||||
<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 +112,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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('SearchPanel', () => {
|
||||
expect(screen.getByText('Result One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
|
||||
await waitFor(() => {
|
||||
const resultTwo = screen.getByText('Result Two').closest('[class*="cursor-pointer"]')!
|
||||
|
||||
@@ -389,63 +389,11 @@ describe('Sidebar', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import type { VaultEntry, FolderNode, SidebarSelection } from '../types'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { buildDynamicSections, sortSections } from '../utils/sidebarSections'
|
||||
import { TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
@@ -12,14 +12,15 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Trash, Archive, CaretLeft, GitDiff, Pulse, Tray,
|
||||
FileText, Trash, Archive, CaretLeft, Tray,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
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 +34,10 @@ interface SidebarProps {
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
onToggleTypeVisibility?: (typeName: string) => void
|
||||
modifiedCount?: number
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => void
|
||||
inboxCount?: number
|
||||
onCommitPush?: () => void
|
||||
onCollapse?: () => void
|
||||
isGitVault?: boolean
|
||||
}
|
||||
|
||||
// --- Hooks ---
|
||||
@@ -123,7 +123,7 @@ function SortableSection({ group, sectionProps }: {
|
||||
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: isCollapsed ? '0 6px' : '4px 6px' }} {...attributes}>
|
||||
<SectionContent
|
||||
group={group} items={items} isCollapsed={isCollapsed}
|
||||
selection={sectionProps.selection} onSelect={sectionProps.onSelect}
|
||||
@@ -140,21 +140,6 @@ function SortableSection({ group, sectionProps }: {
|
||||
)
|
||||
}
|
||||
|
||||
function CommitButton({ modifiedCount, onClick }: { modifiedCount: number; onClick?: () => void }) {
|
||||
if (!onClick) return 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>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
|
||||
const { onMouseDown } = useDragRegion()
|
||||
return (
|
||||
@@ -223,7 +208,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility,
|
||||
modifiedCount = 0, inboxCount = 0, onCommitPush, onCollapse, isGitVault = false,
|
||||
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -306,10 +291,10 @@ 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>
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
@@ -331,16 +316,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>
|
||||
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
default: return false
|
||||
}
|
||||
@@ -26,7 +27,7 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
|
||||
|
||||
// --- 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 +35,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 +45,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 +62,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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -26,7 +26,7 @@ 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?.() },
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 },
|
||||
@@ -65,6 +66,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)
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onGoForward: config.onGoForward,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
})
|
||||
|
||||
@@ -200,4 +200,22 @@ describe('useAppKeyboard', () => {
|
||||
fireKey('o', { metaKey: true, shiftKey: true })
|
||||
expect(onOpenInNewWindow).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+I triggers toggle inspector', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleInspector = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleInspector }))
|
||||
fireKey('i', { metaKey: true, shiftKey: true })
|
||||
expect(onToggleInspector).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Shift+I does not trigger AI chat toggle', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
const onToggleInspector = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat, onToggleInspector }))
|
||||
fireKey('i', { metaKey: true, shiftKey: true })
|
||||
expect(onToggleInspector).toHaveBeenCalled()
|
||||
expect(onToggleAIChat).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ interface KeyboardActions {
|
||||
onGoForward?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleInspector?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
}
|
||||
@@ -64,7 +65,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onOpenInNewWindow, activeTabPathRef,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onOpenInNewWindow, activeTabPathRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
|
||||
@@ -99,6 +100,12 @@ export function useAppKeyboard({
|
||||
onSearch()
|
||||
return
|
||||
}
|
||||
// Cmd+Shift+I: toggle properties/inspector panel
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
|
||||
e.preventDefault()
|
||||
onToggleInspector?.()
|
||||
return
|
||||
}
|
||||
// Cmd+Shift+O: open active note in new window
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'o' || e.key === 'O')) {
|
||||
e.preventDefault()
|
||||
@@ -111,5 +118,5 @@ export function useAppKeyboard({
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onOpenInNewWindow])
|
||||
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onOpenInNewWindow])
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirro
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight'
|
||||
import { markdownLanguage } from '../extensions/markdownHighlight'
|
||||
import { zoomCursorFix } from '../extensions/zoomCursorFix'
|
||||
|
||||
const FONT_FAMILY = '"Berkeley Mono", "JetBrains Mono", "Fira Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
|
||||
export interface CodeMirrorCallbacks {
|
||||
onDocChange: (doc: string) => void
|
||||
@@ -109,6 +110,7 @@ export function useCodeMirror(
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
buildSaveKeymap(callbacksRef),
|
||||
buildBaseTheme(),
|
||||
markdownLanguage(),
|
||||
frontmatterHighlightTheme(),
|
||||
frontmatterHighlightPlugin,
|
||||
zoomCursorFix(),
|
||||
|
||||
@@ -125,6 +125,42 @@ describe('useEditorSaveWithLinks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('handleContentChange calls updateEntry with frontmatter patch when type changes', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Project\nstatus: Active\n---\nBody')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Project', status: 'Active' })
|
||||
})
|
||||
|
||||
it('handleContentChange does NOT call updateEntry for frontmatter when unchanged', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
const content = '---\ntype: Essay\n---\nBody text'
|
||||
|
||||
act(() => { result.current.handleContentChange('/note.md', content) })
|
||||
const callCount = updateEntry.mock.calls.length
|
||||
|
||||
act(() => { result.current.handleContentChange('/note.md', content + ' more') })
|
||||
// Same frontmatter, only body changed — no extra updateEntry for frontmatter
|
||||
expect(updateEntry).toHaveBeenCalledTimes(callCount)
|
||||
})
|
||||
|
||||
it('handleContentChange updates entry when type changes in frontmatter', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Essay\n---\nBody')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Essay' })
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/note.md', '---\ntype: Note\n---\nBody')
|
||||
})
|
||||
expect(updateEntry).toHaveBeenCalledWith('/note.md', { isA: 'Note' })
|
||||
})
|
||||
|
||||
it('spreads all properties from useEditorSave onto the return value', () => {
|
||||
const { result } = renderHookWithLinks()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useEditorSave } from './useEditorSave'
|
||||
import { extractOutgoingLinks, extractSnippet, countWords } from '../utils/wikilinks'
|
||||
import { contentToEntryPatch } from './frontmatterOps'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export function useEditorSaveWithLinks(config: {
|
||||
@@ -21,6 +22,7 @@ export function useEditorSaveWithLinks(config: {
|
||||
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
const prevLinksKeyRef = useRef('')
|
||||
const prevFmKeyRef = useRef('')
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
rawOnChange(path, content)
|
||||
const links = extractOutgoingLinks(content)
|
||||
@@ -29,6 +31,12 @@ export function useEditorSaveWithLinks(config: {
|
||||
prevLinksKeyRef.current = key
|
||||
updateEntry(path, { outgoingLinks: links })
|
||||
}
|
||||
const fmPatch = contentToEntryPatch(content)
|
||||
const fmKey = JSON.stringify(fmPatch)
|
||||
if (fmKey !== prevFmKeyRef.current) {
|
||||
prevFmKeyRef.current = fmKey
|
||||
if (Object.keys(fmPatch).length > 0) updateEntry(path, fmPatch)
|
||||
}
|
||||
}, [rawOnChange, updateEntry])
|
||||
return { ...editor, handleContentChange }
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
interface HealthReport {
|
||||
stray_files: string[]
|
||||
title_mismatches: { path: string; filename: string; title: string; expected_filename: string }[]
|
||||
}
|
||||
|
||||
interface FlatVaultMigration {
|
||||
/** True if stray files were detected in non-protected subfolders. */
|
||||
needsMigration: boolean
|
||||
/** List of stray file paths (relative to vault root). */
|
||||
strayFiles: string[]
|
||||
/** Dismiss the migration prompt without migrating. */
|
||||
dismiss: () => void
|
||||
/** Run flatten_vault and reload. Returns the count of files moved. */
|
||||
migrate: () => Promise<number>
|
||||
/** True while migration is running. */
|
||||
isMigrating: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the vault has files in non-protected subfolders and offers
|
||||
* to flatten them to the vault root. Runs once on vault load.
|
||||
*/
|
||||
export function useFlatVaultMigration(
|
||||
vaultPath: string,
|
||||
entriesLoaded: boolean,
|
||||
reloadVault: () => Promise<unknown>,
|
||||
): FlatVaultMigration {
|
||||
const [strayFiles, setStrayFiles] = useState<string[]>([])
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [isMigrating, setIsMigrating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!entriesLoaded || !vaultPath || !isTauri()) return
|
||||
let cancelled = false
|
||||
invoke<HealthReport>('vault_health_check', { vaultPath })
|
||||
.then((report) => {
|
||||
if (!cancelled && report.stray_files.length > 0) {
|
||||
setStrayFiles(report.stray_files)
|
||||
}
|
||||
})
|
||||
.catch(() => { /* non-critical */ })
|
||||
return () => { cancelled = true }
|
||||
}, [vaultPath, entriesLoaded])
|
||||
|
||||
const dismiss = useCallback(() => setDismissed(true), [])
|
||||
|
||||
const migrate = useCallback(async () => {
|
||||
setIsMigrating(true)
|
||||
try {
|
||||
const count = await invoke<number>('flatten_vault', { vaultPath })
|
||||
setStrayFiles([])
|
||||
setDismissed(true)
|
||||
await reloadVault()
|
||||
return count
|
||||
} finally {
|
||||
setIsMigrating(false)
|
||||
}
|
||||
}, [vaultPath, reloadVault])
|
||||
|
||||
return {
|
||||
needsMigration: strayFiles.length > 0 && !dismissed,
|
||||
strayFiles,
|
||||
dismiss,
|
||||
migrate,
|
||||
isMigrating,
|
||||
}
|
||||
}
|
||||
65
src/hooks/useLayoutPanels.test.ts
Normal file
65
src/hooks/useLayoutPanels.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useLayoutPanels, COLUMN_MIN_WIDTHS } from './useLayoutPanels'
|
||||
|
||||
describe('useLayoutPanels', () => {
|
||||
it('exports column minimum widths', () => {
|
||||
expect(COLUMN_MIN_WIDTHS.sidebar).toBe(180)
|
||||
expect(COLUMN_MIN_WIDTHS.noteList).toBe(220)
|
||||
expect(COLUMN_MIN_WIDTHS.editor).toBe(800)
|
||||
expect(COLUMN_MIN_WIDTHS.inspector).toBe(240)
|
||||
})
|
||||
|
||||
it('returns default widths', () => {
|
||||
const { result } = renderHook(() => useLayoutPanels())
|
||||
expect(result.current.sidebarWidth).toBe(250)
|
||||
expect(result.current.noteListWidth).toBe(300)
|
||||
expect(result.current.inspectorWidth).toBe(280)
|
||||
})
|
||||
|
||||
it('clamps sidebar resize to minimum', () => {
|
||||
const { result } = renderHook(() => useLayoutPanels())
|
||||
act(() => result.current.handleSidebarResize(-500))
|
||||
expect(result.current.sidebarWidth).toBe(COLUMN_MIN_WIDTHS.sidebar)
|
||||
})
|
||||
|
||||
it('clamps note list resize to minimum', () => {
|
||||
const { result } = renderHook(() => useLayoutPanels())
|
||||
act(() => result.current.handleNoteListResize(-500))
|
||||
expect(result.current.noteListWidth).toBe(COLUMN_MIN_WIDTHS.noteList)
|
||||
})
|
||||
|
||||
it('clamps inspector resize to minimum', () => {
|
||||
const { result } = renderHook(() => useLayoutPanels())
|
||||
act(() => result.current.handleInspectorResize(500))
|
||||
expect(result.current.inspectorWidth).toBe(COLUMN_MIN_WIDTHS.inspector)
|
||||
})
|
||||
|
||||
it('clamps sidebar resize to maximum', () => {
|
||||
const { result } = renderHook(() => useLayoutPanels())
|
||||
act(() => result.current.handleSidebarResize(500))
|
||||
expect(result.current.sidebarWidth).toBe(400)
|
||||
})
|
||||
|
||||
it('clamps note list resize to maximum', () => {
|
||||
const { result } = renderHook(() => useLayoutPanels())
|
||||
act(() => result.current.handleNoteListResize(500))
|
||||
expect(result.current.noteListWidth).toBe(500)
|
||||
})
|
||||
|
||||
it('clamps inspector resize to maximum', () => {
|
||||
const { result } = renderHook(() => useLayoutPanels())
|
||||
act(() => result.current.handleInspectorResize(-500))
|
||||
expect(result.current.inspectorWidth).toBe(500)
|
||||
})
|
||||
|
||||
it('defaults inspector to collapsed', () => {
|
||||
const { result } = renderHook(() => useLayoutPanels())
|
||||
expect(result.current.inspectorCollapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts initial inspector collapsed override', () => {
|
||||
const { result } = renderHook(() => useLayoutPanels({ initialInspectorCollapsed: false }))
|
||||
expect(result.current.inspectorCollapsed).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export function useLayoutPanels() {
|
||||
export const COLUMN_MIN_WIDTHS = {
|
||||
sidebar: 180,
|
||||
noteList: 220,
|
||||
editor: 800,
|
||||
inspector: 240,
|
||||
} as const
|
||||
|
||||
export function useLayoutPanels(options?: { initialInspectorCollapsed?: boolean }) {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(250)
|
||||
const [noteListWidth, setNoteListWidth] = useState(300)
|
||||
const [inspectorWidth, setInspectorWidth] = useState(280)
|
||||
const [inspectorCollapsed, setInspectorCollapsed] = useState(false)
|
||||
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(150, Math.min(400, w + delta))), [])
|
||||
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(200, Math.min(500, w + delta))), [])
|
||||
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(200, Math.min(500, w - delta))), [])
|
||||
const [inspectorCollapsed, setInspectorCollapsed] = useState(options?.initialInspectorCollapsed ?? true)
|
||||
const handleSidebarResize = useCallback((delta: number) => setSidebarWidth((w) => Math.max(COLUMN_MIN_WIDTHS.sidebar, Math.min(400, w + delta))), [])
|
||||
const handleNoteListResize = useCallback((delta: number) => setNoteListWidth((w) => Math.max(COLUMN_MIN_WIDTHS.noteList, Math.min(500, w + delta))), [])
|
||||
const handleInspectorResize = useCallback((delta: number) => setInspectorWidth((w) => Math.max(COLUMN_MIN_WIDTHS.inspector, Math.min(500, w - delta))), [])
|
||||
return { sidebarWidth, noteListWidth, inspectorWidth, inspectorCollapsed, setInspectorCollapsed, handleSidebarResize, handleNoteListResize, handleInspectorResize }
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
resolveTemplate,
|
||||
} from './useNoteCreation'
|
||||
import { needsRenameOnSave } from './useNoteRename'
|
||||
import { frontmatterToEntryPatch, applyRelationshipPatch } from './frontmatterOps'
|
||||
import { frontmatterToEntryPatch, applyRelationshipPatch, contentToEntryPatch } from './frontmatterOps'
|
||||
import { useNoteActions } from './useNoteActions'
|
||||
import type { NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
@@ -440,6 +440,36 @@ describe('applyRelationshipPatch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('contentToEntryPatch', () => {
|
||||
it('extracts type from frontmatter', () => {
|
||||
const content = '---\ntype: Project\nstatus: Active\n---\nBody text'
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Project', status: 'Active' })
|
||||
})
|
||||
|
||||
it('returns empty patch when no frontmatter', () => {
|
||||
expect(contentToEntryPatch('Just a body')).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty patch for empty content', () => {
|
||||
expect(contentToEntryPatch('')).toEqual({})
|
||||
})
|
||||
|
||||
it('extracts color, icon, and aliases', () => {
|
||||
const content = '---\ncolor: red\nicon: star\naliases:\n - Foo\n - Bar\n---\n'
|
||||
expect(contentToEntryPatch(content)).toEqual({ color: 'red', icon: 'star', aliases: ['Foo', 'Bar'] })
|
||||
})
|
||||
|
||||
it('handles is_a as alias for type', () => {
|
||||
const content = '---\nis_a: Essay\n---\n'
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Essay' })
|
||||
})
|
||||
|
||||
it('ignores unknown frontmatter keys', () => {
|
||||
const content = '---\ntype: Note\ncustom: value\n---\n'
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('todayDateString', () => {
|
||||
it('returns date in YYYY-MM-DD format', () => {
|
||||
const result = todayDateString()
|
||||
|
||||
@@ -257,7 +257,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
|
||||
openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
|
||||
}, type)
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending, setToastMessage])
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
|
||||
createNoteForRelationship({
|
||||
|
||||
@@ -128,6 +128,15 @@ describe('useVaultLoader', () => {
|
||||
expect(result.current.entries[0].archived).toBe(true)
|
||||
expect(result.current.entries[0].status).toBe('Done')
|
||||
})
|
||||
|
||||
it('preserves entries reference when path does not exist (no-op)', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
const entriesBefore = result.current.entries
|
||||
|
||||
act(() => { result.current.updateEntry('/vault/note/nonexistent.md', { archived: true }) })
|
||||
|
||||
expect(result.current.entries).toBe(entriesBefore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNoteStatus', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState, startTransition } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus, GitPushResult } from '../types'
|
||||
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult } from '../types'
|
||||
import { clearPrefetchCache } from './useTabManagement'
|
||||
|
||||
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
|
||||
@@ -89,6 +89,7 @@ export function resolveNoteStatus(
|
||||
|
||||
export function useVaultLoader(vaultPath: string) {
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [folders, setFolders] = useState<FolderNode[]>([])
|
||||
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
|
||||
const [modifiedFilesError, setModifiedFilesError] = useState<string | null>(null)
|
||||
const tracker = useNewNoteTracker()
|
||||
@@ -97,10 +98,13 @@ export function useVaultLoader(vaultPath: string) {
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
|
||||
setEntries([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
|
||||
setEntries([]); setFolders([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
|
||||
loadVaultData(vaultPath)
|
||||
.then(({ entries: e }) => { setEntries(e) })
|
||||
.catch((err) => console.warn('Vault scan failed:', err))
|
||||
tauriCall<FolderNode[]>('list_vault_folders', { path: vaultPath })
|
||||
.then((f) => { setFolders(f ?? []) })
|
||||
.catch(() => { /* folders are optional — ignore errors */ })
|
||||
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable
|
||||
|
||||
const loadModifiedFiles = useCallback(async () => {
|
||||
@@ -129,8 +133,16 @@ export function useVaultLoader(vaultPath: string) {
|
||||
})
|
||||
}, [tracker])
|
||||
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) =>
|
||||
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e)), [])
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
|
||||
setEntries((prev) => {
|
||||
let changed = false
|
||||
const next = prev.map((e) => {
|
||||
if (e.path === path) { changed = true; return { ...e, ...patch } }
|
||||
return e
|
||||
})
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeEntry = useCallback((path: string) => {
|
||||
setEntries((prev) => prev.filter((e) => e.path !== path))
|
||||
@@ -168,7 +180,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
)
|
||||
|
||||
return {
|
||||
entries, modifiedFiles, modifiedFilesError,
|
||||
entries, folders, modifiedFiles, modifiedFilesError,
|
||||
addEntry, updateEntry, removeEntry, replaceEntry,
|
||||
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
|
||||
getNoteStatus, commitAndPush, reloadVault,
|
||||
|
||||
@@ -18,8 +18,8 @@ function loadViewMode(): ViewMode {
|
||||
return 'all'
|
||||
}
|
||||
|
||||
export function useViewMode() {
|
||||
const [viewMode, setViewModeState] = useState<ViewMode>(loadViewMode)
|
||||
export function useViewMode(initialOverride?: ViewMode) {
|
||||
const [viewMode, setViewModeState] = useState<ViewMode>(initialOverride ?? loadViewMode)
|
||||
|
||||
// Re-sync when vault config becomes available
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,8 +3,6 @@ import { createRoot } from 'react-dom/client'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import NoteWindow from './NoteWindow.tsx'
|
||||
import { isNoteWindow } from './utils/windowMode'
|
||||
|
||||
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
|
||||
// at native level before React's synthetic events can call preventDefault).
|
||||
@@ -14,12 +12,10 @@ if ('__TAURI__' in window || '__TAURI_INTERNALS__' in window) {
|
||||
document.addEventListener('contextmenu', (e) => e.preventDefault(), true)
|
||||
}
|
||||
|
||||
const RootComponent = isNoteWindow() ? NoteWindow : App
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<TooltipProvider>
|
||||
<RootComponent />
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -135,6 +135,7 @@ const trimOrNull = (v: string | null | undefined): string | null => v?.trim() ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
|
||||
export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
list_vault: () => MOCK_ENTRIES,
|
||||
list_vault_folders: () => [],
|
||||
reload_vault: () => MOCK_ENTRIES,
|
||||
reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, trashed: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {} },
|
||||
sync_note_title: () => false,
|
||||
|
||||
@@ -178,4 +178,12 @@ export type InboxPeriod = 'week' | 'month' | 'quarter' | 'all'
|
||||
export type SidebarSelection =
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
| { kind: 'sectionGroup'; type: string }
|
||||
| { kind: 'folder'; path: string }
|
||||
| { kind: 'entity'; entry: VaultEntry }
|
||||
|
||||
/** A node in the vault's folder tree (directories only, no files). */
|
||||
export interface FolderNode {
|
||||
name: string
|
||||
path: string
|
||||
children: FolderNode[]
|
||||
}
|
||||
|
||||
73
src/utils/commitMessage.test.ts
Normal file
73
src/utils/commitMessage.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateCommitMessage } from './commitMessage'
|
||||
import type { ModifiedFile } from '../types'
|
||||
|
||||
function file(relativePath: string, status: ModifiedFile['status'] = 'modified'): ModifiedFile {
|
||||
return { path: `/vault/${relativePath}`, relativePath, status }
|
||||
}
|
||||
|
||||
describe('generateCommitMessage', () => {
|
||||
it('returns empty string for no files', () => {
|
||||
expect(generateCommitMessage([])).toBe('')
|
||||
})
|
||||
|
||||
it('uses note title for a single modified file', () => {
|
||||
expect(generateCommitMessage([file('winter-2026.md')])).toBe('Update winter-2026')
|
||||
})
|
||||
|
||||
it('strips .md extension from the name', () => {
|
||||
expect(generateCommitMessage([file('thoughts-on-testing.md')])).toBe('Update thoughts-on-testing')
|
||||
})
|
||||
|
||||
it('uses basename for files in subdirectories', () => {
|
||||
expect(generateCommitMessage([file('notes/deep/idea.md')])).toBe('Update idea')
|
||||
})
|
||||
|
||||
it('lists 2 files comma-separated', () => {
|
||||
const msg = generateCommitMessage([file('alpha.md'), file('beta.md')])
|
||||
expect(msg).toBe('Update alpha, beta')
|
||||
})
|
||||
|
||||
it('lists 3 files comma-separated', () => {
|
||||
const msg = generateCommitMessage([
|
||||
file('alpha.md'),
|
||||
file('beta.md'),
|
||||
file('gamma.md'),
|
||||
])
|
||||
expect(msg).toBe('Update alpha, beta, gamma')
|
||||
})
|
||||
|
||||
it('uses count for 4+ files', () => {
|
||||
const msg = generateCommitMessage([
|
||||
file('a.md'),
|
||||
file('b.md'),
|
||||
file('c.md'),
|
||||
file('d.md'),
|
||||
])
|
||||
expect(msg).toBe('Update 4 notes')
|
||||
})
|
||||
|
||||
it('says "Add" for a single new/untracked file', () => {
|
||||
expect(generateCommitMessage([file('new-idea.md', 'untracked')])).toBe('Add new-idea')
|
||||
})
|
||||
|
||||
it('says "Add" for a single added file', () => {
|
||||
expect(generateCommitMessage([file('new-idea.md', 'added')])).toBe('Add new-idea')
|
||||
})
|
||||
|
||||
it('says "Delete" for a single deleted file', () => {
|
||||
expect(generateCommitMessage([file('old-note.md', 'deleted')])).toBe('Delete old-note')
|
||||
})
|
||||
|
||||
it('says "Rename" for a single renamed file', () => {
|
||||
expect(generateCommitMessage([file('renamed.md', 'renamed')])).toBe('Rename renamed')
|
||||
})
|
||||
|
||||
it('uses "Update" when statuses are mixed', () => {
|
||||
const msg = generateCommitMessage([
|
||||
file('alpha.md', 'modified'),
|
||||
file('beta.md', 'untracked'),
|
||||
])
|
||||
expect(msg).toBe('Update alpha, beta')
|
||||
})
|
||||
})
|
||||
33
src/utils/commitMessage.ts
Normal file
33
src/utils/commitMessage.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ModifiedFile } from '../types'
|
||||
|
||||
const VERB_MAP: Record<string, string> = {
|
||||
modified: 'Update',
|
||||
added: 'Add',
|
||||
untracked: 'Add',
|
||||
deleted: 'Delete',
|
||||
renamed: 'Rename',
|
||||
}
|
||||
|
||||
const MAX_LISTED_FILES = 3
|
||||
|
||||
function noteName(relativePath: string): string {
|
||||
const basename = relativePath.split('/').pop() ?? relativePath
|
||||
return basename.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
function verb(files: ModifiedFile[]): string {
|
||||
const statuses = new Set(files.map((f) => f.status))
|
||||
if (statuses.size === 1) return VERB_MAP[files[0].status] ?? 'Update'
|
||||
return 'Update'
|
||||
}
|
||||
|
||||
/** Generate a heuristic commit message from modified files. */
|
||||
export function generateCommitMessage(files: ModifiedFile[]): string {
|
||||
if (files.length === 0) return ''
|
||||
const action = verb(files)
|
||||
if (files.length <= MAX_LISTED_FILES) {
|
||||
const names = files.map((f) => noteName(f.relativePath)).join(', ')
|
||||
return `${action} ${names}`
|
||||
}
|
||||
return `${action} ${files.length} notes`
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { compactMarkdown } from './compact-markdown'
|
||||
describe('compactMarkdown', () => {
|
||||
it('collapses blank lines between bullet list items (tight list)', () => {
|
||||
const input = '* Item one\n\n* Item two\n\n* Item three\n'
|
||||
expect(compactMarkdown(input)).toBe('* Item one\n* Item two\n* Item three\n')
|
||||
expect(compactMarkdown(input)).toBe('- Item one\n- Item two\n- Item three\n')
|
||||
})
|
||||
|
||||
it('collapses blank lines between dash list items', () => {
|
||||
@@ -19,7 +19,7 @@ describe('compactMarkdown', () => {
|
||||
|
||||
it('removes extra blank line after heading', () => {
|
||||
const input = '## Personal\n\n* Back on track\n\n* Good health\n'
|
||||
expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track\n* Good health\n')
|
||||
expect(compactMarkdown(input)).toBe('## Personal\n\n- Back on track\n- Good health\n')
|
||||
})
|
||||
|
||||
it('preserves single blank line between heading and content', () => {
|
||||
@@ -54,17 +54,17 @@ describe('compactMarkdown', () => {
|
||||
|
||||
it('handles the exact bug scenario from the issue', () => {
|
||||
const input = '## Personal\n\n* Back on track with Flavia\n\n* Good health vitals in place\n\n'
|
||||
expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track with Flavia\n* Good health vitals in place\n')
|
||||
expect(compactMarkdown(input)).toBe('## Personal\n\n- Back on track with Flavia\n- Good health vitals in place\n')
|
||||
})
|
||||
|
||||
it('handles heading followed immediately by list (no blank line)', () => {
|
||||
const input = '## Title\n* Item one\n\n* Item two\n'
|
||||
expect(compactMarkdown(input)).toBe('## Title\n* Item one\n* Item two\n')
|
||||
expect(compactMarkdown(input)).toBe('## Title\n- Item one\n- Item two\n')
|
||||
})
|
||||
|
||||
it('handles mixed content: heading, paragraph, list', () => {
|
||||
const input = '# Title\n\nSome intro.\n\n* Item one\n\n* Item two\n\nConclusion.\n'
|
||||
expect(compactMarkdown(input)).toBe('# Title\n\nSome intro.\n\n* Item one\n* Item two\n\nConclusion.\n')
|
||||
expect(compactMarkdown(input)).toBe('# Title\n\nSome intro.\n\n- Item one\n- Item two\n\nConclusion.\n')
|
||||
})
|
||||
|
||||
it('preserves empty input', () => {
|
||||
@@ -82,21 +82,41 @@ describe('compactMarkdown', () => {
|
||||
|
||||
it('handles list after code block', () => {
|
||||
const input = '```\ncode\n```\n\n* Item one\n\n* Item two\n'
|
||||
expect(compactMarkdown(input)).toBe('```\ncode\n```\n\n* Item one\n* Item two\n')
|
||||
expect(compactMarkdown(input)).toBe('```\ncode\n```\n\n- Item one\n- Item two\n')
|
||||
})
|
||||
|
||||
it('handles nested list items', () => {
|
||||
const input = '* Parent\n\n * Child one\n\n * Child two\n'
|
||||
expect(compactMarkdown(input)).toBe('* Parent\n * Child one\n * Child two\n')
|
||||
expect(compactMarkdown(input)).toBe('- Parent\n - Child one\n - Child two\n')
|
||||
})
|
||||
|
||||
it('handles checklist items', () => {
|
||||
const input = '* [ ] Todo one\n\n* [ ] Todo two\n\n* [x] Done\n'
|
||||
expect(compactMarkdown(input)).toBe('* [ ] Todo one\n* [ ] Todo two\n* [x] Done\n')
|
||||
expect(compactMarkdown(input)).toBe('- [ ] Todo one\n- [ ] Todo two\n- [x] Done\n')
|
||||
})
|
||||
|
||||
it('handles blockquotes normally', () => {
|
||||
const input = '> Quote line one\n\n> Quote line two\n'
|
||||
expect(compactMarkdown(input)).toBe('> Quote line one\n\n> Quote line two\n')
|
||||
})
|
||||
|
||||
it('does not normalize * inside code blocks', () => {
|
||||
const input = '```\n* not a list\n```\n'
|
||||
expect(compactMarkdown(input)).toBe('```\n* not a list\n```\n')
|
||||
})
|
||||
|
||||
it('decodes   HTML entities from BlockNote bold+code output', () => {
|
||||
const input = '**Remove **`NoteWindow`** and render the full **`App`** component.**\n'
|
||||
expect(compactMarkdown(input)).toBe('**Remove **`NoteWindow`** and render the full **`App`** component.**\n')
|
||||
})
|
||||
|
||||
it('decodes multiple HTML entity types', () => {
|
||||
const input = 'Use & for ampersand and < for less-than.\n'
|
||||
expect(compactMarkdown(input)).toBe('Use & for ampersand and < for less-than.\n')
|
||||
})
|
||||
|
||||
it('does not decode HTML entities inside code blocks', () => {
|
||||
const input = '```\n  should stay\n```\n'
|
||||
expect(compactMarkdown(input)).toBe('```\n  should stay\n```\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Post-process BlockNote's blocksToMarkdownLossy output to produce
|
||||
* standard-convention Markdown:
|
||||
* - Tight lists (no blank lines between consecutive list items)
|
||||
* - Bullet list markers normalized to `-` (BlockNote outputs `*`)
|
||||
* - HTML entities like ` ` decoded back to spaces
|
||||
* - No runs of 3+ blank lines (collapsed to one blank line)
|
||||
* - No trailing blank lines
|
||||
* - Code block content is never modified
|
||||
@@ -14,7 +16,7 @@ export function compactMarkdown(md: string): string {
|
||||
let inCodeBlock = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
let line = lines[i]
|
||||
|
||||
// Track fenced code blocks — never modify content inside them
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
@@ -28,6 +30,12 @@ export function compactMarkdown(md: string): string {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize bullet markers: BlockNote uses `*`, convention is `-`
|
||||
line = normalizeBulletMarker(line)
|
||||
|
||||
// Decode HTML entities that BlockNote inserts (e.g.   for spaces)
|
||||
line = decodeHtmlEntities(line)
|
||||
|
||||
// Skip blank lines that sit between two list items (tight list rule)
|
||||
if (line.trim() === '') {
|
||||
if (isBlankBetweenListItems(lines, i)) continue
|
||||
@@ -80,3 +88,15 @@ function findNextNonBlank(lines: string[], idx: number): number | null {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const BULLET_RE = /^(\s*)\*(\s)/
|
||||
/** Normalize `*` bullet markers to `-` (BlockNote default → standard convention) */
|
||||
function normalizeBulletMarker(line: string): string {
|
||||
return line.replace(BULLET_RE, '$1-$2')
|
||||
}
|
||||
|
||||
/** Decode HTML entities that BlockNote inserts (  & etc.) */
|
||||
function decodeHtmlEntities(line: string): string {
|
||||
if (!line.includes('&#x')) return line
|
||||
return line.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseFrontmatter } from './frontmatter'
|
||||
import { parseFrontmatter, detectFrontmatterState } from './frontmatter'
|
||||
|
||||
describe('parseFrontmatter', () => {
|
||||
describe('boolean-like Yes/No values', () => {
|
||||
@@ -44,3 +44,37 @@ describe('parseFrontmatter', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectFrontmatterState', () => {
|
||||
it('returns "none" for null content', () => {
|
||||
expect(detectFrontmatterState(null)).toBe('none')
|
||||
})
|
||||
|
||||
it('returns "none" when no --- block exists', () => {
|
||||
expect(detectFrontmatterState('Just a plain markdown file')).toBe('none')
|
||||
})
|
||||
|
||||
it('returns "empty" for empty frontmatter block', () => {
|
||||
expect(detectFrontmatterState('---\n---\nBody')).toBe('empty')
|
||||
})
|
||||
|
||||
it('returns "empty" for whitespace-only frontmatter', () => {
|
||||
expect(detectFrontmatterState('---\n \n---\nBody')).toBe('empty')
|
||||
})
|
||||
|
||||
it('returns "valid" for well-formed frontmatter', () => {
|
||||
expect(detectFrontmatterState('---\ntitle: Hello\ntype: Note\n---\nBody')).toBe('valid')
|
||||
})
|
||||
|
||||
it('returns "valid" for frontmatter with only a title', () => {
|
||||
expect(detectFrontmatterState('---\ntitle: Test\n---\n')).toBe('valid')
|
||||
})
|
||||
|
||||
it('returns "invalid" for malformed YAML (missing colon)', () => {
|
||||
expect(detectFrontmatterState('---\nthis is not yaml\n---\nBody')).toBe('invalid')
|
||||
})
|
||||
|
||||
it('returns "invalid" for frontmatter with only garbage text', () => {
|
||||
expect(detectFrontmatterState('---\n{broken: [yaml\n---\nBody')).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,20 @@ function parseScalar(value: string): FrontmatterValue {
|
||||
return clean
|
||||
}
|
||||
|
||||
export type FrontmatterState = 'valid' | 'empty' | 'none' | 'invalid'
|
||||
|
||||
/** Detect whether content has valid, empty, missing, or invalid frontmatter. */
|
||||
export function detectFrontmatterState(content: string | null): FrontmatterState {
|
||||
if (!content) return 'none'
|
||||
const match = content.match(/^---\n([\s\S]*?)---/)
|
||||
if (!match) return 'none'
|
||||
const body = match[1].trim()
|
||||
if (!body) return 'empty'
|
||||
// Valid frontmatter needs at least one line starting with a word character followed by colon
|
||||
const hasValidLine = body.split('\n').some(line => /^[A-Za-z][\w -]*:/.test(line))
|
||||
return hasValidLine ? 'valid' : 'invalid'
|
||||
}
|
||||
|
||||
/** Parse YAML frontmatter from content */
|
||||
export function parseFrontmatter(content: string | null): ParsedFrontmatter {
|
||||
if (!content) return {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, buildValidLinkTargets, isInboxEntry, filterInboxEntries } from './noteListHelpers'
|
||||
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig, buildValidLinkTargets, isInboxEntry, filterInboxEntries, filterEntries } from './noteListHelpers'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
@@ -695,3 +695,47 @@ describe('filterInboxEntries', () => {
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterEntries — folder selection', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/projects/laputa/note1.md', title: 'Note 1' }),
|
||||
makeEntry({ path: '/vault/projects/laputa/note2.md', title: 'Note 2' }),
|
||||
makeEntry({ path: '/vault/projects/portfolio/site.md', title: 'Site' }),
|
||||
makeEntry({ path: '/vault/areas/health.md', title: 'Health' }),
|
||||
makeEntry({ path: '/vault/root-note.md', title: 'Root' }),
|
||||
]
|
||||
|
||||
it('filters entries by folder path', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' })
|
||||
expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2'])
|
||||
})
|
||||
|
||||
it('does not include notes from sibling folders', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'projects/laputa' })
|
||||
expect(result.find(e => e.title === 'Site')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('filters recursively — includes notes from subfolders', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'projects' })
|
||||
expect(result.map(e => e.title)).toEqual(['Note 1', 'Note 2', 'Site'])
|
||||
})
|
||||
|
||||
it('filters direct children', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'areas' })
|
||||
expect(result.map(e => e.title)).toEqual(['Health'])
|
||||
})
|
||||
|
||||
it('returns empty for root when entries are in subfolders', () => {
|
||||
const result = filterEntries(entries, { kind: 'folder', path: 'nonexistent' })
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes archived and trashed entries by default', () => {
|
||||
const withArchived = [
|
||||
...entries,
|
||||
makeEntry({ path: '/vault/projects/laputa/archived.md', title: 'Archived', archived: true }),
|
||||
]
|
||||
const result = filterEntries(withArchived, { kind: 'folder', path: 'projects/laputa' })
|
||||
expect(result.find(e => e.title === 'Archived')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user