Compare commits
62 Commits
v0.2026033
...
v0.2026033
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.84
|
||||
AVERAGE_THRESHOLD=9.38
|
||||
@@ -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
|
||||
29
docs/adr/0032-status-bar-for-git-actions.md
Normal file
29
docs/adr/0032-status-bar-for-git-actions.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0032"
|
||||
title: "Git actions (Changes, Pulse, Commit) in status bar, not sidebar"
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
|
||||
## 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
|
||||
33
docs/adr/0033-subfolder-scanning-and-folder-tree.md
Normal file
33
docs/adr/0033-subfolder-scanning-and-folder-tree.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0033"
|
||||
title: "Subfolder scanning and folder tree navigation"
|
||||
status: active
|
||||
date: 2026-03-31
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
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).
|
||||
@@ -86,3 +86,6 @@ 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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 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};
|
||||
|
||||
@@ -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]
|
||||
@@ -1211,6 +1218,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'] || '',
|
||||
|
||||
122
src/App.tsx
122
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'
|
||||
@@ -54,6 +55,9 @@ 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 +73,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 +81,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,7 +96,25 @@ 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()
|
||||
@@ -110,6 +133,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 +174,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 +257,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])
|
||||
@@ -211,6 +289,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 +322,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 +429,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 +477,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} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -419,6 +515,7 @@ function App() {
|
||||
onDeleteProperty={notes.handleDeleteProperty}
|
||||
onAddProperty={notes.handleAddProperty}
|
||||
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
|
||||
onInitializeProperties={handleInitializeProperties}
|
||||
showAIChat={dialogs.showAIChat}
|
||||
onToggleAIChat={dialogs.toggleAIChat}
|
||||
vaultPath={resolvedPath}
|
||||
@@ -462,13 +559,14 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
117
src/components/FolderTree.tsx
Normal file
117
src/components/FolderTree.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState, useCallback, 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
|
||||
}
|
||||
|
||||
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 }: FolderTreeProps) {
|
||||
const [sectionCollapsed, setSectionCollapsed] = useState(false)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
const toggleFolder = useCallback((path: string) => {
|
||||
setExpanded((prev) => ({ ...prev, [path]: !prev[path] }))
|
||||
}, [])
|
||||
|
||||
if (folders.length === 0) 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>
|
||||
<Plus size={12} className="text-muted-foreground" />
|
||||
</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}
|
||||
/>
|
||||
))}
|
||||
</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 />}
|
||||
|
||||
@@ -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,9 @@ interface SidebarProps {
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
onToggleTypeVisibility?: (typeName: string) => void
|
||||
modifiedCount?: number
|
||||
folders?: FolderNode[]
|
||||
inboxCount?: number
|
||||
onCommitPush?: () => void
|
||||
onCollapse?: () => void
|
||||
isGitVault?: boolean
|
||||
}
|
||||
|
||||
// --- Hooks ---
|
||||
@@ -123,7 +122,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 +139,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 +207,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 = [], inboxCount = 0, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -306,10 +290,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 +315,11 @@ export const Sidebar = memo(function Sidebar({
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Folder tree */}
|
||||
<FolderTree folders={folders} selection={selection} onSelect={onSelect} />
|
||||
</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 && activeBadgeStyle ? 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,37 @@ 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 & Push button next to Changes badge', () => {
|
||||
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('hides Commit & Push button when no modified files', () => {
|
||||
render(<StatusBar noteCount={100} modifiedCount={0} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCommitPush={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, onCommitPush }: { count: number; onClick?: () => void; onCommitPush?: () => void }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<>
|
||||
@@ -341,7 +345,50 @@ 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>
|
||||
{onCommitPush && (
|
||||
<span
|
||||
role="button"
|
||||
onClick={onCommitPush}
|
||||
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} style={{ color: 'var(--accent-orange)' }} />
|
||||
</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 +428,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)
|
||||
@@ -390,7 +437,7 @@ 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 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<VaultMenu vaults={vaults} vaultPath={vaultPath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onConnectGitHub={onConnectGitHub} hasGitHub={hasGitHub} onRemoveVault={onRemoveVault} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
@@ -402,14 +449,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>
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} onCommitPush={onCommitPush} />
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<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 }
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -168,7 +172,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`
|
||||
}
|
||||
@@ -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,42 @@ 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 by parent folder (non-recursive — direct children only)', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -313,8 +313,21 @@ function applySubFilter(entries: VaultEntry[], subFilter: NoteListFilter): Vault
|
||||
return entries.filter(isActive)
|
||||
}
|
||||
|
||||
function isInFolder(entryPath: string, folderRelPath: string): boolean {
|
||||
const sep = '/'
|
||||
const suffix = sep + folderRelPath + sep
|
||||
const dirEnd = entryPath.lastIndexOf(sep)
|
||||
if (dirEnd < 0) return false
|
||||
const entryDir = entryPath.slice(0, dirEnd + 1)
|
||||
return entryDir.endsWith(suffix)
|
||||
}
|
||||
|
||||
function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter): VaultEntry[] {
|
||||
if (selection.kind === 'entity') return []
|
||||
if (selection.kind === 'folder') {
|
||||
const folderEntries = entries.filter((e) => isInFolder(e.path, selection.path))
|
||||
return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive)
|
||||
}
|
||||
if (selection.kind === 'sectionGroup') {
|
||||
const typeEntries = entries.filter((e) => e.isA === selection.type)
|
||||
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
|
||||
|
||||
@@ -19,56 +19,55 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
}
|
||||
|
||||
describe('attachClickHandlers', () => {
|
||||
it('adds onItemClick to each candidate', () => {
|
||||
const vaultPath = '/vault'
|
||||
|
||||
it('inserts relative path stem as wikilink target', () => {
|
||||
const insertWikilink = vi.fn()
|
||||
const candidates = [
|
||||
{ title: 'Note A', aliases: [], group: 'Note', entryTitle: 'Note A', path: '/a.md' },
|
||||
{ title: 'Note B', aliases: [], group: 'Project', entryTitle: 'Note B', path: '/b.md' },
|
||||
{ title: 'Note A', aliases: [], group: 'Note', entryTitle: 'Note A', path: '/vault/a.md' },
|
||||
{ title: 'Note B', aliases: [], group: 'Project', entryTitle: 'Note B', path: '/vault/b.md' },
|
||||
]
|
||||
|
||||
const result = attachClickHandlers(candidates, insertWikilink)
|
||||
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
result[0].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('Note A')
|
||||
expect(insertWikilink).toHaveBeenCalledWith('a|Note A')
|
||||
result[1].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('Note B')
|
||||
expect(insertWikilink).toHaveBeenCalledWith('b|Note B')
|
||||
})
|
||||
|
||||
it('preserves all original properties', () => {
|
||||
const result = attachClickHandlers(
|
||||
[{ title: 'X', aliases: ['y'], group: 'Topic', entryTitle: 'X', path: '/x.md' }],
|
||||
[{ title: 'X', aliases: ['y'], group: 'Topic', entryTitle: 'X', path: '/vault/x.md' }],
|
||||
vi.fn(),
|
||||
vaultPath,
|
||||
)
|
||||
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/x.md' })
|
||||
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/vault/x.md' })
|
||||
})
|
||||
|
||||
it('uses slug|title target when candidates have duplicate titles', () => {
|
||||
it('includes subfolder path in wikilink target', () => {
|
||||
const insertWikilink = vi.fn()
|
||||
const candidates = [
|
||||
{ title: 'Status Update', aliases: [], group: 'Project', entryTitle: 'Status Update', path: '/vault/status-update.md' },
|
||||
{ title: 'Status Update', aliases: [], group: 'Journal', entryTitle: 'Status Update', path: '/vault/status-update-2.md' },
|
||||
{ title: 'ADR 001', aliases: [], group: 'Note', entryTitle: 'ADR 001', path: '/vault/docs/adr/0001-tauri-stack.md' },
|
||||
]
|
||||
|
||||
const result = attachClickHandlers(candidates, insertWikilink)
|
||||
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
|
||||
|
||||
result[0].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('status-update|Status Update')
|
||||
result[1].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('status-update-2|Status Update')
|
||||
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack|ADR 001')
|
||||
})
|
||||
|
||||
it('uses title-only target when titles are unique', () => {
|
||||
it('omits pipe display when title matches path stem', () => {
|
||||
const insertWikilink = vi.fn()
|
||||
const candidates = [
|
||||
{ title: 'Alpha', aliases: [], group: 'Note', entryTitle: 'Alpha', path: '/vault/alpha.md' },
|
||||
{ title: 'Beta', aliases: [], group: 'Note', entryTitle: 'Beta', path: '/vault/beta.md' },
|
||||
{ title: 'roadmap', aliases: [], group: 'Note', entryTitle: 'roadmap', path: '/vault/roadmap.md' },
|
||||
]
|
||||
|
||||
const result = attachClickHandlers(candidates, insertWikilink)
|
||||
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
|
||||
|
||||
result[0].onItemClick()
|
||||
expect(insertWikilink).toHaveBeenCalledWith('Alpha')
|
||||
expect(insertWikilink).toHaveBeenCalledWith('roadmap')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { deduplicateByPath, disambiguateTitles } from './wikilinkSuggestions'
|
||||
import { bestSearchRank } from './fuzzyMatch'
|
||||
import { filterSuggestionItems } from '@blocknote/core/extensions'
|
||||
import type { WikilinkSuggestionItem } from '../components/WikilinkSuggestionMenu'
|
||||
import { relativePathStem } from './wikilink'
|
||||
|
||||
const MAX_RESULTS = 20
|
||||
|
||||
@@ -16,31 +17,25 @@ interface BaseSuggestionItem {
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Build a filename-based target with pipe display: "slug|Title" */
|
||||
function buildPathTarget(item: BaseSuggestionItem): string {
|
||||
const filename = item.path.split('/').pop() ?? ''
|
||||
const slug = filename.replace(/\.md$/, '')
|
||||
return `${slug}|${item.entryTitle}`
|
||||
/** Build the wikilink target: relative path stem with pipe display for the title.
|
||||
* e.g. "docs/adr/0001-tauri-stack|Tauri Stack" for subfolders,
|
||||
* "roadmap|Roadmap" for root files. */
|
||||
function buildTarget(item: BaseSuggestionItem, vaultPath: string): string {
|
||||
const stem = relativePathStem(item.path, vaultPath)
|
||||
return stem === item.entryTitle ? stem : `${stem}|${item.entryTitle}`
|
||||
}
|
||||
|
||||
/** Add onItemClick to raw suggestion candidates.
|
||||
* When multiple candidates share the same title, inserts a path-based
|
||||
* target with pipe syntax so the wikilink uniquely identifies the note. */
|
||||
* Always inserts the vault-relative path as the wikilink target
|
||||
* so links are unambiguous and work across subfolders. */
|
||||
export function attachClickHandlers(
|
||||
candidates: BaseSuggestionItem[],
|
||||
insertWikilink: (target: string) => void,
|
||||
vaultPath: string,
|
||||
) {
|
||||
const titleCounts = new Map<string, number>()
|
||||
for (const item of candidates) {
|
||||
titleCounts.set(item.entryTitle, (titleCounts.get(item.entryTitle) ?? 0) + 1)
|
||||
}
|
||||
|
||||
return candidates.map(item => ({
|
||||
...item,
|
||||
onItemClick: () => {
|
||||
const isDuplicate = (titleCounts.get(item.entryTitle) ?? 0) > 1
|
||||
insertWikilink(isDuplicate ? buildPathTarget(item) : item.entryTitle)
|
||||
},
|
||||
onItemClick: () => insertWikilink(buildTarget(item, vaultPath)),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { wikilinkTarget, wikilinkDisplay, resolveEntry } from './wikilink'
|
||||
import { wikilinkTarget, wikilinkDisplay, resolveEntry, relativePathStem } from './wikilink'
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
return {
|
||||
@@ -96,4 +96,31 @@ describe('resolveEntry', () => {
|
||||
// Searching for "bar" should match barEntry (by filename stem) not fooEntry (by title)
|
||||
expect(resolveEntry(ambiguous, 'bar')).toBe(barEntry)
|
||||
})
|
||||
|
||||
it('resolves path-style target by matching path suffix', () => {
|
||||
const adr = makeEntry({ path: '/vault/docs/adr/0031-foo.md', filename: '0031-foo.md', title: '0031 Foo' })
|
||||
const flat = makeEntry({ path: '/vault/hello.md', filename: 'hello.md', title: 'Hello' })
|
||||
expect(resolveEntry([adr, flat], 'docs/adr/0031-foo')).toBe(adr)
|
||||
})
|
||||
|
||||
it('disambiguates same-name files in different subfolders via path', () => {
|
||||
const alpha = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha' })
|
||||
const alphaArchived = makeEntry({ path: '/vault/archive/alpha.md', filename: 'alpha.md', title: 'Alpha' })
|
||||
expect(resolveEntry([alpha, alphaArchived], 'projects/alpha')).toBe(alpha)
|
||||
expect(resolveEntry([alpha, alphaArchived], 'archive/alpha')).toBe(alphaArchived)
|
||||
})
|
||||
})
|
||||
|
||||
describe('relativePathStem', () => {
|
||||
it('extracts relative path stem from absolute path and vault path', () => {
|
||||
expect(relativePathStem('/Users/luca/Vault/note.md', '/Users/luca/Vault')).toBe('note')
|
||||
})
|
||||
|
||||
it('preserves subdirectory structure', () => {
|
||||
expect(relativePathStem('/Users/luca/Vault/docs/adr/0031.md', '/Users/luca/Vault')).toBe('docs/adr/0031')
|
||||
})
|
||||
|
||||
it('falls back to filename stem when vault path does not match', () => {
|
||||
expect(relativePathStem('/other/path/note.md', '/Users/luca/Vault')).toBe('note')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,39 +18,53 @@ export function wikilinkDisplay(ref: string): string {
|
||||
return last.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
/** Extract the vault-relative path stem (no leading slash, no .md extension). */
|
||||
export function relativePathStem(absolutePath: string, vaultPath: string): string {
|
||||
const prefix = vaultPath.endsWith('/') ? vaultPath : vaultPath + '/'
|
||||
if (absolutePath.startsWith(prefix)) return absolutePath.slice(prefix.length).replace(/\.md$/, '')
|
||||
// Fallback: just the filename stem
|
||||
const filename = absolutePath.split('/').pop() ?? absolutePath
|
||||
return filename.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified wikilink resolution: find the VaultEntry matching a wikilink target.
|
||||
* Handles pipe syntax, case-insensitive matching.
|
||||
* Resolution order (multi-pass, global priority):
|
||||
* 1. Filename stem match (strongest — filename IS the identity in flat vault)
|
||||
* 2. Alias match
|
||||
* 3. Exact title match
|
||||
* 4. Humanized title match (kebab-case → words)
|
||||
* No path-based matching — flat vault uses title/filename only.
|
||||
* Legacy path-style targets like "person/alice" are handled by extracting the last segment.
|
||||
* 1. Path-suffix match (for path-style targets like "docs/adr/0031-foo")
|
||||
* 2. Filename stem match (strongest for flat vaults)
|
||||
* 3. Alias match
|
||||
* 4. Exact title match
|
||||
* 5. Humanized title match (kebab-case → words)
|
||||
*/
|
||||
export function resolveEntry(entries: VaultEntry[], rawTarget: string): VaultEntry | undefined {
|
||||
const key = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
|
||||
const keyLower = key.toLowerCase()
|
||||
// For legacy path-style targets like "person/alice", extract just the last segment
|
||||
const lastSegment = key.includes('/') ? (key.split('/').pop() ?? key) : key
|
||||
const lastSegmentLower = lastSegment.toLowerCase()
|
||||
const asWords = lastSegmentLower.replace(/-/g, ' ')
|
||||
const pathSuffix = key.includes('/') ? '/' + key.toLowerCase() + '.md' : null
|
||||
|
||||
// Pass 1: filename stem (strongest match — filename IS identity in flat vault)
|
||||
// Pass 1: path-suffix match (for subfolder targets like "docs/adr/0031-foo")
|
||||
if (pathSuffix) {
|
||||
for (const e of entries) {
|
||||
if (e.path.toLowerCase().endsWith(pathSuffix)) return e
|
||||
}
|
||||
}
|
||||
// Pass 2: filename stem (strongest for flat vault)
|
||||
for (const e of entries) {
|
||||
const stem = e.filename.replace(/\.md$/, '').toLowerCase()
|
||||
if (stem === keyLower || stem === lastSegmentLower) return e
|
||||
}
|
||||
// Pass 2: alias
|
||||
// Pass 3: alias
|
||||
for (const e of entries) {
|
||||
if (e.aliases.some(a => a.toLowerCase() === keyLower)) return e
|
||||
}
|
||||
// Pass 3: exact title
|
||||
// Pass 4: exact title
|
||||
for (const e of entries) {
|
||||
if (e.title.toLowerCase() === keyLower || e.title.toLowerCase() === lastSegmentLower) return e
|
||||
}
|
||||
// Pass 4: humanized title (kebab-case → words)
|
||||
// Pass 5: humanized title (kebab-case → words)
|
||||
if (asWords !== keyLower) {
|
||||
for (const e of entries) {
|
||||
if (e.title.toLowerCase() === asWords) return e
|
||||
|
||||
@@ -25,7 +25,8 @@ export function preFilterWikilinks<T extends WikilinkBaseItem>(
|
||||
return items.filter(item =>
|
||||
item.title.toLowerCase().includes(lowerQuery) ||
|
||||
item.aliases.some(a => a.toLowerCase().includes(lowerQuery)) ||
|
||||
item.group.toLowerCase().includes(lowerQuery)
|
||||
item.group.toLowerCase().includes(lowerQuery) ||
|
||||
item.path.toLowerCase().includes(lowerQuery)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const EDITOR_CONTAINER = '.editor__blocknote-container'
|
||||
|
||||
test.describe('Clickable editor empty space — click below content focuses editor', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Open the first note to mount the editor
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5_000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('container onClick handler focuses the editor', async ({ page }) => {
|
||||
// Dispatch a click directly on the container element (simulating empty space click)
|
||||
// This is equivalent to a user clicking on the container background, which happens
|
||||
// when the editor content is shorter than the container.
|
||||
const focused = await page.evaluate((sel) => {
|
||||
const container = document.querySelector(sel)
|
||||
if (!container) return 'no-container'
|
||||
container.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
||||
const active = document.activeElement
|
||||
if (!active) return 'no-active'
|
||||
return container.contains(active) ? 'focused' : `active-is-${active.tagName}`
|
||||
}, EDITOR_CONTAINER)
|
||||
expect(focused).toBe('focused')
|
||||
})
|
||||
|
||||
test('editor container has cursor:text CSS for visual affordance', async ({ page }) => {
|
||||
// Verify the cursor:text rule is in the stylesheet
|
||||
const hasCursorText = await page.evaluate(() => {
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
for (const rule of sheet.cssRules) {
|
||||
if (rule instanceof CSSStyleRule &&
|
||||
rule.selectorText?.includes('editor__blocknote-container') &&
|
||||
rule.style.cursor === 'text') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin sheets throw */ }
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasCursorText).toBe(true)
|
||||
})
|
||||
|
||||
test('clicking on actual editor content does not disrupt normal editing', async ({ page }) => {
|
||||
const editor = page.locator('.bn-editor').first()
|
||||
await editor.waitFor({ timeout: 5_000 })
|
||||
|
||||
await editor.click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const editorHasFocus = await page.evaluate(() => {
|
||||
const active = document.activeElement
|
||||
if (!active) return false
|
||||
const container = document.querySelector('.editor__blocknote-container')
|
||||
return container?.contains(active) ?? false
|
||||
})
|
||||
expect(editorHasFocus).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Emoji icon shown everywhere title appears', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2500)
|
||||
})
|
||||
|
||||
test('emoji icon appears in editor and note list after setting it', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Remove any existing icon first for a clean state
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
if (await iconDisplay.isVisible()) {
|
||||
await iconDisplay.click()
|
||||
await page.locator('[data-testid="note-icon-remove"]').click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// Set an emoji icon
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const firstEmoji = page.locator('[data-testid="emoji-option"]').first()
|
||||
const emojiText = await firstEmoji.textContent()
|
||||
expect(emojiText).toBeTruthy()
|
||||
await firstEmoji.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify emoji in note list item
|
||||
const noteListText = await noteItem.textContent()
|
||||
expect(noteListText).toContain(emojiText!)
|
||||
|
||||
// Verify emoji appears in the editor NoteIcon area
|
||||
// Wait for frontmatter update to propagate through the single-note reload cycle
|
||||
const iconAfterSet = page.locator('[data-testid="note-icon-display"]')
|
||||
await expect(iconAfterSet).toBeVisible({ timeout: 8000 })
|
||||
await expect(iconAfterSet).toHaveText(emojiText!, { timeout: 3000 })
|
||||
})
|
||||
|
||||
test('note without emoji shows no emoji span in tab or note list', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Remove icon if present
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
if (await iconDisplay.isVisible()) {
|
||||
await iconDisplay.click()
|
||||
await page.locator('[data-testid="note-icon-remove"]').click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// The note list item title row should not contain an emoji span (mr-1)
|
||||
const titleRow = noteItem.locator('.truncate.text-foreground').first()
|
||||
const emojiSpans = titleRow.locator('.mr-1')
|
||||
await expect(emojiSpans).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Filter pills height matches breadcrumb bar', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('filter pills row height equals breadcrumb bar height (45px)', async ({ page }) => {
|
||||
// Click on the "Projects" type section in the sidebar
|
||||
await page.getByText('Projects', { exact: true }).click()
|
||||
|
||||
// Filter pills should now be visible
|
||||
const pills = page.getByTestId('filter-pills')
|
||||
await expect(pills).toBeVisible()
|
||||
|
||||
// Verify height matches breadcrumb bar (45px)
|
||||
const pillsBox = await pills.boundingBox()
|
||||
expect(pillsBox).not.toBeNull()
|
||||
expect(pillsBox!.height).toBe(45)
|
||||
|
||||
// Verify all three pills are present
|
||||
await expect(page.getByTestId('filter-pill-open')).toBeVisible()
|
||||
await expect(page.getByTestId('filter-pill-archived')).toBeVisible()
|
||||
await expect(page.getByTestId('filter-pill-trashed')).toBeVisible()
|
||||
|
||||
// Open pill should be active by default
|
||||
const openPill = page.getByTestId('filter-pill-open')
|
||||
await expect(openPill).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
// Verify pills are keyboard accessible (Tab + Enter)
|
||||
await openPill.focus()
|
||||
await expect(openPill).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
const archivedPill = page.getByTestId('filter-pill-archived')
|
||||
await expect(archivedPill).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(archivedPill).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const DROP_OVERLAY = '.editor__drop-overlay'
|
||||
const EDITOR_CONTAINER = '.editor__blocknote-container'
|
||||
|
||||
test.describe('Image drop overlay — internal drag does not trigger overlay', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Open the first note to mount the editor
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5_000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(300)
|
||||
await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('internal drag (no image files) does not show the overlay', async ({ page }) => {
|
||||
// Simulate an internal drag (e.g. block or tab) — dataTransfer has no files
|
||||
await page.locator(EDITOR_CONTAINER).first().dispatchEvent('dragover', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
|
||||
// The overlay should NOT appear for non-file drags
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('dragover with image file shows the overlay', async ({ page }) => {
|
||||
// Simulate an OS file drag with an image file in dataTransfer
|
||||
// Playwright dispatchEvent can't set dataTransfer items directly,
|
||||
// so we use page.evaluate to dispatch a proper DragEvent with file items
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
|
||||
const event = new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
el.dispatchEvent(event)
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
await expect(page.locator(DROP_OVERLAY)).toContainText('Drop image here')
|
||||
})
|
||||
|
||||
test('dragleave after image dragover hides the overlay', async ({ page }) => {
|
||||
// First show the overlay via image dragover
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
el.dispatchEvent(new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
|
||||
// Now simulate dragleave (cursor left the container)
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
el.dispatchEvent(new DragEvent('dragleave', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
relatedTarget: document.body,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('drop resets the overlay', async ({ page }) => {
|
||||
// Show overlay via image dragover
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
el.dispatchEvent(new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
|
||||
// Simulate drop
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
el.dispatchEvent(new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Flat vault migration', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('title field appears above editor when note is open', async ({ page }) => {
|
||||
// Click a note in the sidebar to open it
|
||||
const noteItem = page.locator('[data-testid="note-list-item"]').first()
|
||||
if (await noteItem.isVisible()) {
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
// The TitleField should be visible
|
||||
const titleField = page.locator('[data-testid="title-field"]')
|
||||
await expect(titleField).toBeVisible()
|
||||
// The input should have the note's title
|
||||
const input = page.locator('[data-testid="title-field-input"]')
|
||||
const value = await input.inputValue()
|
||||
expect(value.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('title field is editable and shows filename on change', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-item"]').first()
|
||||
if (await noteItem.isVisible()) {
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
const input = page.locator('[data-testid="title-field-input"]')
|
||||
await input.focus()
|
||||
// Should show filename indicator when focused
|
||||
const filenameIndicator = page.locator('[data-testid="title-field-filename"]')
|
||||
await expect(filenameIndicator).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('no migration banner when vault is already flat (mock)', async ({ page }) => {
|
||||
// In browser mode (mock), vault should be flat and no migration banner
|
||||
const banner = page.locator('[data-testid="migration-banner"]')
|
||||
await page.waitForTimeout(1000)
|
||||
await expect(banner).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('H1 heading is hidden in editor (CSS rule active)', async ({ page }) => {
|
||||
// Check that the CSS rule for hiding H1 is present in the document
|
||||
const styles = await page.evaluate(() => {
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
for (const rule of sheet.cssRules) {
|
||||
if (rule.cssText?.includes('data-content-type="heading"') && rule.cssText?.includes('display: none')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin */ }
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(styles).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,136 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
test.describe('Note icon emoji picker — full emoji set, search, continuous scroll', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Open the first note to mount the editor
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5_000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.waitForSelector('[data-testid="note-icon-area"]', { timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('add-icon button opens emoji picker', async ({ page }) => {
|
||||
// Hover the note-icon-area to reveal the Add icon button
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
const addBtn = page.locator('[data-testid="note-icon-add"]')
|
||||
await addBtn.waitFor({ timeout: 3_000 })
|
||||
await addBtn.click()
|
||||
await expect(page.locator('[data-testid="emoji-picker"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('emoji picker contains full emoji set (1800+)', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const emojiCount = await page.locator('[data-testid="emoji-option"]').count()
|
||||
expect(emojiCount).toBeGreaterThan(1800)
|
||||
})
|
||||
|
||||
test('emoji search by English name works (rocket → 🚀)', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const searchInput = page.locator('[data-testid="emoji-picker-search"]')
|
||||
await searchInput.fill('rocket')
|
||||
await page.waitForTimeout(100)
|
||||
const results = page.locator('[data-testid="emoji-option"]')
|
||||
const count = await results.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
// Verify 🚀 is in the results
|
||||
const texts = await results.allTextContents()
|
||||
expect(texts).toContain('🚀')
|
||||
})
|
||||
|
||||
test('emoji search for "heart" returns results', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-picker-search"]').fill('heart')
|
||||
await page.waitForTimeout(100)
|
||||
const results = page.locator('[data-testid="emoji-option"]')
|
||||
const count = await results.count()
|
||||
expect(count).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
test('emoji search for "fire" finds 🔥', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-picker-search"]').fill('fire')
|
||||
await page.waitForTimeout(100)
|
||||
const texts = await page.locator('[data-testid="emoji-option"]').allTextContents()
|
||||
expect(texts).toContain('🔥')
|
||||
})
|
||||
|
||||
test('all emojis visible in continuous scroll (no category lock)', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const grid = page.locator('[data-testid="emoji-picker-grid"]')
|
||||
// Verify the grid is scrollable (content height > container height)
|
||||
const isScrollable = await grid.evaluate(el => el.scrollHeight > el.clientHeight)
|
||||
expect(isScrollable).toBe(true)
|
||||
// All emojis should be in the DOM (continuous scroll, not category-locked)
|
||||
const emojiCount = await page.locator('[data-testid="emoji-option"]').count()
|
||||
expect(emojiCount).toBeGreaterThan(1800)
|
||||
})
|
||||
|
||||
test('selecting an emoji saves it and shows it in editor', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
// Pick the first emoji
|
||||
const firstEmoji = page.locator('[data-testid="emoji-option"]').first()
|
||||
const emojiText = await firstEmoji.textContent()
|
||||
await firstEmoji.click()
|
||||
// Picker should close and emoji should be displayed
|
||||
await expect(page.locator('[data-testid="emoji-picker"]')).not.toBeVisible()
|
||||
const display = page.locator('[data-testid="note-icon-display"]')
|
||||
await expect(display).toBeVisible()
|
||||
await expect(display).toHaveText(emojiText!)
|
||||
})
|
||||
|
||||
test('clicking set emoji shows change/remove menu', async ({ page }) => {
|
||||
// First set an emoji
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-option"]').first().click()
|
||||
// Now click the displayed emoji to open menu
|
||||
await page.locator('[data-testid="note-icon-display"]').click()
|
||||
await expect(page.locator('[data-testid="note-icon-menu"]')).toBeVisible()
|
||||
await expect(page.locator('[data-testid="note-icon-change"]')).toBeVisible()
|
||||
await expect(page.locator('[data-testid="note-icon-remove"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('remove emoji removes it from display', async ({ page }) => {
|
||||
// Set an emoji first
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-option"]').first().click()
|
||||
await expect(page.locator('[data-testid="note-icon-display"]')).toBeVisible()
|
||||
// Remove it
|
||||
await page.locator('[data-testid="note-icon-display"]').click()
|
||||
await page.locator('[data-testid="note-icon-remove"]').click()
|
||||
await expect(page.locator('[data-testid="note-icon-display"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('command palette "Set Note Icon" opens picker', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Set Note Icon')
|
||||
await expect(page.locator('[data-testid="emoji-picker"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Escape closes the emoji picker', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await expect(page.locator('[data-testid="emoji-picker"]')).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('[data-testid="emoji-picker"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('no results shows empty state message', async ({ page }) => {
|
||||
await page.locator('[data-testid="note-icon-area"]').hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-picker-search"]').fill('xyzzyplugh')
|
||||
await page.waitForTimeout(100)
|
||||
await expect(page.getByText('No emojis found')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, findCommand, executeCommand, sendShortcut } from './helpers'
|
||||
|
||||
test.describe('Note icon emoji picker', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(2500)
|
||||
})
|
||||
|
||||
test('emoji picker opens from Add Icon button and selects emoji', async ({ page }) => {
|
||||
// Open a note by clicking the first item in the note list
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// The note icon area should be visible
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await expect(iconArea).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Hover to reveal the "Add icon" button and click it
|
||||
await iconArea.hover()
|
||||
const addButton = page.locator('[data-testid="note-icon-add"]')
|
||||
await expect(addButton).toBeVisible()
|
||||
await addButton.click()
|
||||
|
||||
// Emoji picker should appear
|
||||
const picker = page.locator('[data-testid="emoji-picker"]')
|
||||
await expect(picker).toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Click the first emoji
|
||||
const emojiOption = picker.locator('[data-testid="emoji-option"]').first()
|
||||
await emojiOption.click()
|
||||
|
||||
// Picker should close
|
||||
await expect(picker).not.toBeVisible()
|
||||
|
||||
// The icon should now be displayed
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
await expect(iconDisplay).toBeVisible({ timeout: 2000 })
|
||||
})
|
||||
|
||||
test('emoji icon can be removed via menu', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Add an icon first
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
await page.locator('[data-testid="emoji-option"]').first().click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Click the displayed icon to show the menu
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
await iconDisplay.click()
|
||||
|
||||
// Menu should show Remove option
|
||||
const removeButton = page.locator('[data-testid="note-icon-remove"]')
|
||||
await expect(removeButton).toBeVisible()
|
||||
await removeButton.click()
|
||||
|
||||
// Icon should be removed, add button returns on hover
|
||||
await expect(iconDisplay).not.toBeVisible()
|
||||
await iconArea.hover()
|
||||
await expect(page.locator('[data-testid="note-icon-add"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cmd+K shows Set Note Icon command', async ({ page }) => {
|
||||
// Open a note first
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open command palette and search for the icon command
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'Set Note Icon')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('Set Note Icon command opens emoji picker', async ({ page }) => {
|
||||
// Open a note first
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Use command palette to open emoji picker
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Set Note Icon')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Emoji picker should be visible
|
||||
const picker = page.locator('[data-testid="emoji-picker"]')
|
||||
await expect(picker).toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Select an emoji
|
||||
await picker.locator('[data-testid="emoji-option"]').first().click()
|
||||
|
||||
// Icon should be displayed
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
await expect(iconDisplay).toBeVisible({ timeout: 2000 })
|
||||
})
|
||||
|
||||
test('Escape closes the emoji picker', async ({ page }) => {
|
||||
// Open a note
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open picker
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
|
||||
const picker = page.locator('[data-testid="emoji-picker"]')
|
||||
await expect(picker).toBeVisible()
|
||||
|
||||
// Press Escape to close
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(picker).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('emoji icon is shown in Quick Open (Cmd+P) results', async ({ page }) => {
|
||||
// Open a note and set an icon
|
||||
const noteItem = page.locator('[data-testid="note-list-container"] .cursor-pointer').first()
|
||||
await noteItem.waitFor({ timeout: 5000 })
|
||||
// Get the note title for later search
|
||||
const noteTitle = await noteItem.locator('.font-medium, .text-sm').first().textContent()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Set an icon
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.locator('[data-testid="note-icon-add"]').click()
|
||||
const firstEmoji = page.locator('[data-testid="emoji-option"]').first()
|
||||
const emojiText = await firstEmoji.textContent()
|
||||
await firstEmoji.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open Quick Open and search for the note
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
|
||||
if (noteTitle && emojiText) {
|
||||
await searchInput.fill(noteTitle.trim().substring(0, 10))
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verify the emoji appears in the search results
|
||||
const results = page.locator('.py-1 .cursor-pointer .truncate')
|
||||
const resultTexts = await results.allTextContents()
|
||||
const hasEmoji = resultTexts.some(t => t.includes(emojiText))
|
||||
expect(hasEmoji).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
test.describe('Note list shows complete relationships when opening from sidebar', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('entity view shows relationship groups with correct counts after sidebar click', async ({ page }) => {
|
||||
// Navigate to Responsibilities type to find entity notes in the sidebar
|
||||
// First, click on a sidebar type that contains entity notes
|
||||
// We'll use the sidebar search to filter to "Sponsorships"
|
||||
const searchToggle = page.locator('[data-testid="search-toggle"]')
|
||||
if (await searchToggle.isVisible()) {
|
||||
await searchToggle.click()
|
||||
}
|
||||
|
||||
// Type in sidebar search to find Sponsorships
|
||||
const sidebarSearch = page.locator('[data-testid="note-list-search"]')
|
||||
if (await sidebarSearch.isVisible()) {
|
||||
await sidebarSearch.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// Click the Sponsorships note in the note list to trigger entity view
|
||||
const sponsorshipsItem = page.locator('[data-entry-path*="sponsorships"]').first()
|
||||
if (await sponsorshipsItem.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await sponsorshipsItem.click()
|
||||
await page.waitForTimeout(1000)
|
||||
} else {
|
||||
// Fallback: open via quick open — then click in sidebar
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
await searchInput.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
// The inspector panel (right side) should show relationship labels with font-mono-overline
|
||||
// This verifies the note loaded and relationships were parsed correctly
|
||||
const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
|
||||
await expect(measuresLabel).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const proceduresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' })
|
||||
await expect(proceduresLabel).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Verify the relationships panel shows the correct number of items
|
||||
// Has Measures should have 2 entries (measure-sponsorship-mrr, measure-close-rate)
|
||||
const measuresSection = measuresLabel.locator('xpath=ancestor::div[1]')
|
||||
const measureItems = measuresSection.locator('a, button').filter({ hasText: /measure|Measure/ })
|
||||
// At least 1 resolved relationship entry
|
||||
const hasItems = await measureItems.count()
|
||||
expect(hasItems).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('relationship labels persist after navigating away and back', async ({ page }) => {
|
||||
// Open Sponsorships via quick open
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
await searchInput.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Verify Has Measures relationship appears in inspector
|
||||
const measuresLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
|
||||
await expect(measuresLabel).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Navigate to different note
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput2 = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput2).toBeVisible()
|
||||
await searchInput2.fill('Start Laputa App')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Navigate back to Sponsorships
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
const searchInput3 = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput3).toBeVisible()
|
||||
await searchInput3.fill('Sponsorships')
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Relationships should still be visible — not stale or incomplete
|
||||
const measuresLabelAgain = page.locator('span.font-mono-overline').filter({ hasText: 'Has Measures' })
|
||||
await expect(measuresLabelAgain).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const proceduresLabelAgain = page.locator('span.font-mono-overline').filter({ hasText: 'Has Procedures' })
|
||||
await expect(proceduresLabelAgain).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Note list preview snippet', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('snippet does not contain raw markdown formatting', async ({ page }) => {
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await expect(noteListContainer).toBeVisible()
|
||||
|
||||
const snippets = noteListContainer.locator('[data-testid="note-snippet"]')
|
||||
const count = await snippets.count()
|
||||
|
||||
for (let i = 0; i < Math.min(count, 8); i++) {
|
||||
const text = await snippets.nth(i).textContent()
|
||||
if (text && text.length > 10) {
|
||||
expect(text).not.toMatch(/\*\*[^*]+\*\*/)
|
||||
expect(text).not.toContain('```')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('snippet does not start with list markers', async ({ page }) => {
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await expect(noteListContainer).toBeVisible()
|
||||
|
||||
const snippets = noteListContainer.locator('[data-testid="note-snippet"]')
|
||||
const count = await snippets.count()
|
||||
|
||||
for (let i = 0; i < Math.min(count, 10); i++) {
|
||||
const text = await snippets.nth(i).textContent()
|
||||
if (text && text.length > 15) {
|
||||
expect(text.trimStart()).not.toMatch(/^[*\-+] /)
|
||||
expect(text.trimStart()).not.toMatch(/^\d+\. /)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user