Compare commits
46 Commits
v0.2026032
...
v0.2026033
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
14b5c34b94 | ||
|
|
a7a61d9751 | ||
|
|
68066b857f | ||
|
|
858468aec6 | ||
|
|
67ac8db888 | ||
|
|
1ae1377b2d | ||
|
|
adfceb3c70 | ||
|
|
46856b4dc2 | ||
|
|
2746fb88ad | ||
|
|
52d66048d6 | ||
|
|
1a90679f62 | ||
|
|
59773725e1 |
42
.claude/commands/laputa-done.md
Normal file
42
.claude/commands/laputa-done.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# /laputa-done <task_id>
|
||||
|
||||
Mark a Laputa task as done: add completion comment, move to In Review, notify Brian, then self-dispatch the next task.
|
||||
|
||||
Run this after Phase 1 (Playwright) and Phase 2 (native app QA) both pass.
|
||||
|
||||
## 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 Brian**
|
||||
|
||||
```bash
|
||||
openclaw system event --text "laputa-task-done:$ARGUMENTS" --mode now
|
||||
```
|
||||
|
||||
**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.85
|
||||
AVERAGE_THRESHOLD=9.39
|
||||
@@ -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,42 @@ 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
|
||||
thresholds_file = '$THRESHOLDS_FILE'
|
||||
new_hotspot = max(hotspot_min, round(hotspot, 2))
|
||||
new_average = max(average_min, round(average, 2))
|
||||
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
|
||||
|
||||
153
CLAUDE.md
153
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,90 @@ 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)
|
||||
- **⛔ 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 +125,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.
|
||||
|
||||
29
docs/adr/0029-domain-command-builder-pattern.md
Normal file
29
docs/adr/0029-domain-command-builder-pattern.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0029"
|
||||
title: "Domain command builder pattern for useCommandRegistry"
|
||||
status: active
|
||||
date: 2026-03-30
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
`useCommandRegistry` was a 224-line "brain method" (CodeScene hotspot) that defined all command palette commands inline: navigation, note actions, git operations, view toggles, settings, type management, and filter controls. This monolithic structure scored 39 on CodeScene's complexity scale (target: ≤9.5 for hotspots), making it increasingly hard to add new commands without touching the central file.
|
||||
|
||||
## Decision
|
||||
|
||||
**Split command definitions into focused domain modules under `src/hooks/commands/`, each exporting a `build*Commands(config)` factory function. `useCommandRegistry` becomes a thin assembler that calls each builder and merges the results.** Domain modules: `navigationCommands`, `noteCommands`, `gitCommands`, `viewCommands`, `settingsCommands`, `typeCommands`, `filterCommands`. Shared types live in `commands/types.ts`; public API re-exported from `commands/index.ts`.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Domain builder modules — each module owns its command shape and receives typed config. `useCommandRegistry` is pure assembly. All new files score 9.58–10.0. Downside: more files to navigate.
|
||||
- **Option B**: Split by file but keep one large hook calling sub-hooks — sub-hooks still need shared state passed down, similar coupling. No real complexity win.
|
||||
- **Option C**: Register commands imperatively via a global registry — decouples callers entirely. Downside: harder to trace, no TypeScript inference at the registration site, over-engineering for current scale.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Adding a new command means editing the relevant domain module (e.g. `noteCommands.ts`) only, not touching the assembler.
|
||||
- Each domain module receives only the config it needs — explicit, typed interface, no hook dependency.
|
||||
- `useCommandRegistry` reduced from 224 lines to a thin assembler.
|
||||
- Pattern is consistent with the Rust commands/ module split (ADR-0030).
|
||||
- Re-evaluation trigger: if command count grows to the point where the assembler itself becomes a complexity hotspot.
|
||||
29
docs/adr/0030-rust-commands-module-split.md
Normal file
29
docs/adr/0030-rust-commands-module-split.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0030"
|
||||
title: "Rust commands/ module split by domain"
|
||||
status: active
|
||||
date: 2026-03-30
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
`src-tauri/src/commands.rs` grew to 937 lines as Tauri command handlers accumulated for vault CRUD, git/GitHub sync, AI, system, and window operations. All commands shared a single file with no domain separation, making it hard to navigate, review, and extend. The file was a CodeScene hotspot dragging down overall code health.
|
||||
|
||||
## Decision
|
||||
|
||||
**Replace `commands.rs` with a `commands/` module split by domain: `vault.rs`, `git.rs`, `github.rs`, `ai.rs`, `system.rs`, and `mod.rs` (shared utilities + re-exports).** Each file owns the Tauri command handlers for its domain and the `#[cfg(desktop)]` / `#[cfg(mobile)]` stubs for platform-conditional availability. `mod.rs` is kept thin (≤100 lines) with no command logic — only re-exports and shared helpers (`expand_tilde`, `parse_build_label`).
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Domain-based module split — mirrors the TypeScript `hooks/commands/` pattern (ADR-0029). Each file is independently reviewable and scores well on code health. Downside: more files to navigate.
|
||||
- **Option B**: Split by platform (`desktop.rs`, `mobile.rs`) — aligns with `#[cfg(...)]` guards but mixes domain concerns. Harder to find a specific command.
|
||||
- **Option C**: Keep monolith but add section comments — zero file-count cost, but doesn't solve complexity or reviewability.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `github.rs` separates GitHub OAuth/API commands from git sync commands (`git.rs`), matching the underlying Rust module split (`github/` vs `git/`).
|
||||
- Platform stubs (`#[cfg(mobile)]` error returns) live alongside the desktop implementation in the same domain file.
|
||||
- `mod.rs` re-exports all command functions so `lib.rs` `invoke_handler!` registration is unchanged.
|
||||
- New Tauri commands go into the appropriate domain file; if no domain fits, create a new one rather than putting it in `mod.rs`.
|
||||
- Re-evaluation trigger: if a single domain file (e.g. `vault.rs`) itself grows beyond ~300 lines and becomes a hotspot.
|
||||
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
|
||||
@@ -82,4 +82,9 @@ proposed → active → superseded
|
||||
| [0024](0024-cache-outside-vault.md) | Vault cache stored outside vault directory | active |
|
||||
| [0025](0025-type-field-canonical.md) | type: as canonical field (replacing Is A:) | active |
|
||||
| [0026](0026-props-down-no-global-state.md) | Props-down callbacks-up (no global state) | active |
|
||||
| [0027](0027-dual-ai-architecture.md) | Dual AI architecture (API chat + CLI agent) | active |
|
||||
| [0027](0027-dual-ai-architecture.md) | Dual AI architecture (API chat + CLI agent) | 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 |
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"title": "Laputa",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 1200,
|
||||
"minHeight": 400,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
|
||||
@@ -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;
|
||||
|
||||
50
src/App.tsx
50
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,7 @@ 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 './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -69,6 +71,7 @@ const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function App() {
|
||||
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
|
||||
const [inboxPeriod, setInboxPeriod] = useState<InboxPeriod>('month')
|
||||
@@ -76,7 +79,7 @@ function App() {
|
||||
setSelection(sel)
|
||||
setNoteListFilter('open')
|
||||
}, [])
|
||||
const layout = useLayoutPanels()
|
||||
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const dialogs = useDialogs()
|
||||
|
||||
@@ -91,7 +94,7 @@ 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)
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
useVaultConfig(resolvedPath)
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
@@ -124,6 +127,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(() => {
|
||||
@@ -211,6 +236,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 +269,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 +376,18 @@ 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 telemetry consent dialog on first launch (skip for note windows)
|
||||
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
|
||||
return (
|
||||
<TelemetryConsentDialog
|
||||
onAccept={() => {
|
||||
@@ -381,7 +407,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} 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} />
|
||||
</>
|
||||
@@ -462,13 +488,13 @@ 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} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -74,7 +74,7 @@ function DetailBlock({ label, content, isError }: {
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<div
|
||||
className="text-muted-foreground"
|
||||
style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', marginBottom: 2 }}
|
||||
style={{ fontSize: 10, fontWeight: 600, marginBottom: 2 }}
|
||||
>
|
||||
{label}
|
||||
</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,13 +41,20 @@ const trashedEntry: VaultEntry = {
|
||||
|
||||
const defaultProps = {
|
||||
wordCount: 100,
|
||||
noteStatus: 'clean' as const,
|
||||
showDiffToggle: false,
|
||||
diffMode: false,
|
||||
diffLoading: false,
|
||||
onToggleDiff: vi.fn(),
|
||||
}
|
||||
|
||||
describe('BreadcrumbBar — drag region', () => {
|
||||
it('has data-tauri-drag-region on the container', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
const bar = container.firstElementChild as HTMLElement
|
||||
expect(bar.dataset.tauriDragRegion).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — trash/restore', () => {
|
||||
it('shows trash button for non-trashed note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onTrash={vi.fn()} onRestore={vi.fn()} />)
|
||||
@@ -76,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()} />)
|
||||
|
||||
@@ -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
|
||||
@@ -57,7 +55,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 +144,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>
|
||||
@@ -164,49 +162,18 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
}
|
||||
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry, wordCount, noteStatus, ...actionProps
|
||||
entry, ...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
data-tauri-drag-region
|
||||
className="flex shrink-0 items-center justify-end"
|
||||
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>
|
||||
|
||||
{/* Right: action icons */}
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -208,7 +208,8 @@ describe('CommandPalette', () => {
|
||||
const groupHeaders = screen.getAllByText(
|
||||
(_content, el) =>
|
||||
el?.tagName === 'DIV' &&
|
||||
el.classList.contains('uppercase') &&
|
||||
el.classList.contains('text-[11px]') &&
|
||||
el.classList.contains('font-medium') &&
|
||||
!!el.textContent,
|
||||
).map(el => el.textContent)
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
|
||||
runningIndex += items.length
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((cmd, i) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -55,7 +55,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
@@ -66,7 +66,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Type
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
|
||||
@@ -36,7 +36,7 @@ export function CreateTypeDialog({ open, onClose, onCreate }: CreateTypeDialogPr
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Type Name
|
||||
</label>
|
||||
<Input
|
||||
|
||||
@@ -112,8 +112,8 @@ describe('DynamicPropertiesPanel', () => {
|
||||
frontmatter={{ Status: 'Active' }}
|
||||
/>
|
||||
)
|
||||
// Status rendered with CSS text-transform: uppercase, DOM text is still "Active"
|
||||
expect(screen.getByTitle('Active')).toBeInTheDocument()
|
||||
// Status rendered as sentence case
|
||||
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders properties from frontmatter', () => {
|
||||
@@ -124,9 +124,9 @@ describe('DynamicPropertiesPanel', () => {
|
||||
frontmatter={{ cadence: 'Weekly', owner: 'Luca' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('cadence')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cadence')).toBeInTheDocument()
|
||||
expect(screen.getByText('Weekly')).toBeInTheDocument()
|
||||
expect(screen.getByText('owner')).toBeInTheDocument()
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument()
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -162,7 +162,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
frontmatter={{ notion_id: 'abc-123-def' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('notion_id')).toBeInTheDocument()
|
||||
expect(screen.getByText('Notion id')).toBeInTheDocument()
|
||||
expect(screen.getByText('abc-123-def')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
// aliases skipped (in SKIP_KEYS); 'Belongs to' skipped (has wikilinks)
|
||||
expect(screen.queryByText('aliases')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Belongs to')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('cadence')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cadence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows former relationship key with plain text value in Properties', () => {
|
||||
@@ -219,7 +219,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
const typeLabels = screen.getAllByText('Type')
|
||||
// Only the TypeRow label should exist, not a property row
|
||||
expect(typeLabels).toHaveLength(1)
|
||||
expect(screen.getByTitle('Active')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders boolean property as toggle', () => {
|
||||
@@ -232,7 +232,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
/>
|
||||
)
|
||||
// Boolean should show as Yes/No toggle
|
||||
const toggleBtn = screen.getByText('\u2717 No')
|
||||
const toggleBtn = screen.getByText('No')
|
||||
fireEvent.click(toggleBtn)
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('published', true)
|
||||
})
|
||||
@@ -260,7 +260,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('+ Add property')).toBeInTheDocument()
|
||||
expect(screen.getByText('Add property')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens add property form when button clicked', () => {
|
||||
@@ -272,7 +272,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
expect(screen.getByPlaceholderText('Property name')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Value')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-property-type-trigger')).toBeInTheDocument()
|
||||
@@ -287,7 +287,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
const keyInput = screen.getByPlaceholderText('Property name')
|
||||
const valueInput = screen.getByPlaceholderText('Value')
|
||||
fireEvent.change(keyInput, { target: { value: 'priority' } })
|
||||
@@ -412,7 +412,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
/>
|
||||
)
|
||||
// Click status pill to open dropdown
|
||||
fireEvent.click(screen.getByTitle('Active'))
|
||||
fireEvent.click(screen.getByTestId('status-badge'))
|
||||
// Should show dropdown with search input
|
||||
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-search-input')).toBeInTheDocument()
|
||||
@@ -478,11 +478,11 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
const keyInput = screen.getByPlaceholderText('Property name')
|
||||
fireEvent.keyDown(keyInput, { key: 'Escape' })
|
||||
// Form should be hidden, button should reappear
|
||||
expect(screen.getByText('+ Add property')).toBeInTheDocument()
|
||||
expect(screen.getByText('Add property')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds property on Enter in form', () => {
|
||||
@@ -494,7 +494,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
const keyInput = screen.getByPlaceholderText('Property name')
|
||||
const valueInput = screen.getByPlaceholderText('Value')
|
||||
fireEvent.change(keyInput, { target: { value: 'key' } })
|
||||
@@ -512,7 +512,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
const keyInput = screen.getByPlaceholderText('Property name')
|
||||
const valueInput = screen.getByPlaceholderText('Value')
|
||||
fireEvent.change(keyInput, { target: { value: 'tags' } })
|
||||
@@ -530,9 +530,9 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
fireEvent.click(screen.getByTestId('add-property-cancel'))
|
||||
expect(screen.getByText('+ Add property')).toBeInTheDocument()
|
||||
expect(screen.getByText('Add property')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('editable vs read-only distinction', () => {
|
||||
@@ -775,7 +775,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('Mar 31, 2026')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders calendar icon in date trigger button', () => {
|
||||
it('renders date trigger button', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -786,7 +786,6 @@ describe('DynamicPropertiesPanel', () => {
|
||||
)
|
||||
const trigger = screen.getByTestId('date-display')
|
||||
expect(trigger.tagName).toBe('BUTTON')
|
||||
expect(trigger.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens calendar popover when date button clicked', () => {
|
||||
@@ -841,7 +840,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Active'))
|
||||
fireEvent.click(screen.getByTestId('status-badge'))
|
||||
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByTestId('status-search-input'), { key: 'Escape' })
|
||||
expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument()
|
||||
@@ -857,7 +856,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Active'))
|
||||
fireEvent.click(screen.getByTestId('status-badge'))
|
||||
fireEvent.click(screen.getByTestId('status-dropdown-backdrop'))
|
||||
expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument()
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
@@ -872,7 +871,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Active'))
|
||||
fireEvent.click(screen.getByTestId('status-badge'))
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
fireEvent.change(input, { target: { value: 'Needs Review' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
@@ -893,7 +892,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Active'))
|
||||
fireEvent.click(screen.getByTestId('status-badge'))
|
||||
expect(screen.getByTestId('status-option-Reviewing')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-option-Shipped')).toBeInTheDocument()
|
||||
})
|
||||
@@ -910,7 +909,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByText('\u2713 Yes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders boolean toggle for false values', () => {
|
||||
@@ -923,7 +922,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByText('\u2717 No')).toBeInTheDocument()
|
||||
expect(screen.getByText('No')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -937,13 +936,13 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('trashed')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('trashed_at')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('archived_at')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('icon')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Trashed')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Trashed at')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived at')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
|
||||
// Custom property still visible
|
||||
expect(screen.getByText('cadence')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cadence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters system properties case-insensitively', () => {
|
||||
@@ -958,7 +957,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.queryByText('Trashed')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Icon')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('cadence')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cadence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not filter similar but non-matching property names', () => {
|
||||
@@ -971,7 +970,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Is Trashed')).toBeInTheDocument()
|
||||
expect(screen.getByText('archive_date')).toBeInTheDocument()
|
||||
expect(screen.getByText('Archive date')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1055,7 +1054,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByText('\u2713 Yes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders boolean toggle for string "false" when boolean mode overridden', () => {
|
||||
@@ -1069,7 +1068,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByText('\u2717 No')).toBeInTheDocument()
|
||||
expect(screen.getByText('No')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles string boolean from false to true', () => {
|
||||
@@ -1124,7 +1123,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
// Switch type to boolean
|
||||
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('option', { name: /Boolean/ }))
|
||||
@@ -1141,7 +1140,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('option', { name: /Boolean/ }))
|
||||
// Toggle from No to Yes
|
||||
@@ -1158,7 +1157,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
const keyInput = screen.getByPlaceholderText('Property name')
|
||||
fireEvent.change(keyInput, { target: { value: 'published' } })
|
||||
// Switch to boolean type
|
||||
@@ -1180,7 +1179,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('option', { name: /Date/ }))
|
||||
expect(screen.getByTestId('add-property-date-trigger')).toBeInTheDocument()
|
||||
@@ -1196,7 +1195,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
|
||||
fireEvent.click(screen.getByRole('option', { name: /Status/ }))
|
||||
expect(screen.getByTestId('add-property-status-trigger')).toBeInTheDocument()
|
||||
@@ -1211,7 +1210,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onAddProperty={onAddProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('+ Add property'))
|
||||
fireEvent.click(screen.getByText('Add property'))
|
||||
// Default mode is text
|
||||
expect(screen.getByPlaceholderText('Value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -9,6 +9,12 @@ import { AddPropertyForm } from './AddPropertyForm'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import type { PropertyDisplayMode } from '../utils/propertyTypes'
|
||||
|
||||
function toSentenceCase(key: string): string {
|
||||
const spaced = key.replace(/[_-]/g, ' ')
|
||||
if (!spaced) return spaced
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||
export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value)
|
||||
@@ -48,8 +54,8 @@ 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">
|
||||
<span className="font-mono-overline flex min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<span className="truncate">{propKey}</span>
|
||||
<span className="flex min-w-0 items-center gap-1 text-[12px] text-muted-foreground">
|
||||
<span className="truncate">{toSentenceCase(propKey)}</span>
|
||||
{onDelete && (
|
||||
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">×</button>
|
||||
)}
|
||||
@@ -65,7 +71,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="readonly-property">
|
||||
<span className="font-mono-overline min-w-0 truncate" style={{ color: 'var(--text-muted)' }}>{label}</span>
|
||||
<span className="min-w-0 truncate text-[12px] text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -74,10 +80,12 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
|
||||
return (
|
||||
<button
|
||||
className="mt-3 w-full cursor-pointer border border-border bg-transparent text-center text-muted-foreground transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12 }}
|
||||
className="mt-1 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent px-1.5 text-[12px] text-muted-foreground opacity-50 transition-opacity hover:opacity-100 disabled:cursor-not-allowed"
|
||||
onClick={onClick} disabled={disabled}
|
||||
>+ Add property</button>
|
||||
>
|
||||
<span className="text-[12px] leading-none">+</span>
|
||||
Add property
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { normalizeUrl, openExternalUrl } from '../utils/url'
|
||||
import { getTagStyle } from '../utils/tagStyles'
|
||||
|
||||
export function UrlValue({
|
||||
value,
|
||||
@@ -63,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-secondary-foreground transition-colors hover:text-primary hover:underline"
|
||||
className="min-w-0 cursor-pointer truncate rounded-md px-2 py-1 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"
|
||||
@@ -124,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="min-w-0 max-w-full cursor-pointer truncate rounded-md px-2 py-1 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onStartEdit}
|
||||
title={value || 'Click to edit'}
|
||||
>
|
||||
@@ -211,11 +212,12 @@ export function TagPillList({
|
||||
) : (
|
||||
<span
|
||||
key={idx}
|
||||
className="group/pill relative inline-flex cursor-pointer items-center rounded-full px-2 py-0.5 transition-colors"
|
||||
className="group/pill relative inline-flex cursor-pointer items-center rounded-md transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-blue-light)',
|
||||
color: 'var(--accent-blue)',
|
||||
fontSize: 11,
|
||||
...getTagStyle(item),
|
||||
backgroundColor: getTagStyle(item).bg,
|
||||
padding: '4px 8px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
onClick={() => handleStartEdit(idx)}
|
||||
@@ -223,8 +225,8 @@ export function TagPillList({
|
||||
>
|
||||
{item}
|
||||
<button
|
||||
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 shadow-[-6px_0_4px_-2px_var(--accent-blue-light)] transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
|
||||
style={{ color: 'var(--accent-blue)', backgroundColor: 'var(--accent-blue-light)' }}
|
||||
className="absolute right-0.5 top-1/2 flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-full border-none p-0 text-[10px] leading-none opacity-0 transition-all hover:bg-[var(--accent-red-light)] hover:text-[var(--accent-red)] group-hover/pill:opacity-100"
|
||||
style={{ color: getTagStyle(item).color, backgroundColor: getTagStyle(item).bg }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteItem(idx)
|
||||
@@ -253,7 +255,8 @@ export function TagPillList({
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-[var(--accent-blue)] bg-transparent p-0 text-[12px] leading-none text-[var(--accent-blue)] transition-colors hover:bg-[var(--accent-blue-light)]"
|
||||
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: '4px 8px' }}
|
||||
onClick={() => setIsAddingNew(true)}
|
||||
title={`Add ${label.toLowerCase()}`}
|
||||
>
|
||||
|
||||
@@ -179,16 +179,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: 16px;
|
||||
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 --- */
|
||||
@@ -209,10 +228,10 @@
|
||||
}
|
||||
|
||||
.note-icon-button--active {
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
transition: transform 0.1s;
|
||||
margin-right: 8px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.note-icon-button--active:hover:not(:disabled) {
|
||||
@@ -229,7 +248,7 @@
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.note-icon-area:hover .note-icon-button--add,
|
||||
.title-section:hover .note-icon-button--add,
|
||||
.note-icon-button--add:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -290,7 +309,7 @@
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: var(--headings-h1-font-size, 28px);
|
||||
font-size: var(--headings-h1-font-size, 32px);
|
||||
font-weight: var(--headings-h1-font-weight, 700);
|
||||
line-height: var(--headings-h1-line-height, 1.2);
|
||||
letter-spacing: var(--headings-h1-letter-spacing, -0.015em);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
@@ -129,7 +130,6 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
<BreadcrumbBar
|
||||
entry={activeTab.entry}
|
||||
wordCount={wordCount}
|
||||
noteStatus={props.activeStatus}
|
||||
showDiffToggle={props.showDiffToggle}
|
||||
diffMode={props.diffMode}
|
||||
diffLoading={props.diffLoading}
|
||||
@@ -160,6 +160,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
|
||||
@@ -201,15 +202,27 @@ export function EditorContent({
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} />
|
||||
{showEditor && activeTab && (
|
||||
<div className="editor-scroll-area">
|
||||
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
|
||||
<div className="title-section">
|
||||
<div className="title-section__row">
|
||||
<NoteIcon
|
||||
icon={emojiIcon}
|
||||
editable={!isTrashed}
|
||||
onSetIcon={handleSetIcon}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
/>
|
||||
{!emojiIcon && (
|
||||
<div className="title-section__add-icon">
|
||||
<NoteIcon
|
||||
icon={null}
|
||||
editable={!isTrashed}
|
||||
onSetIcon={handleSetIcon}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
|
||||
{emojiIcon && (
|
||||
<NoteIcon
|
||||
icon={emojiIcon}
|
||||
editable={!isTrashed}
|
||||
onSetIcon={handleSetIcon}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
/>
|
||||
)}
|
||||
<TitleField
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function EditorRightPanel({
|
||||
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?.()}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { EMOJI_GROUPS, EMOJIS_BY_GROUP, GROUP_ICONS, GROUP_SHORT_LABELS, searchEmojis } from '../utils/emoji'
|
||||
import { EMOJI_GROUPS, EMOJIS_BY_GROUP, GROUP_SHORT_LABELS, searchEmojis } from '../utils/emoji'
|
||||
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void
|
||||
@@ -11,7 +11,6 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const groupRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
@@ -45,13 +44,6 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
onClose()
|
||||
}, [onSelect, onClose])
|
||||
|
||||
const scrollToGroup = useCallback((group: string) => {
|
||||
const el = groupRefs.current.get(group)
|
||||
if (el && scrollRef.current) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const searchResults = search.trim() ? searchEmojis(search) : null
|
||||
const isSearching = searchResults !== null
|
||||
|
||||
@@ -73,20 +65,6 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
data-testid="emoji-picker-search"
|
||||
/>
|
||||
</div>
|
||||
{!isSearching && (
|
||||
<div className="flex gap-0.5 border-b border-border px-2 py-1.5 overflow-x-auto">
|
||||
{EMOJI_GROUPS.map(group => (
|
||||
<button
|
||||
key={group}
|
||||
className="shrink-0 rounded px-1.5 py-1 text-base transition-colors hover:bg-secondary"
|
||||
onClick={() => scrollToGroup(group)}
|
||||
title={GROUP_SHORT_LABELS[group]}
|
||||
>
|
||||
{GROUP_ICONS[group]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div ref={scrollRef} className="max-h-[300px] overflow-y-auto p-2" data-testid="emoji-picker-grid">
|
||||
{isSearching ? (
|
||||
searchResults.length > 0 ? (
|
||||
@@ -113,10 +91,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
const emojis = EMOJIS_BY_GROUP.get(group)
|
||||
if (!emojis?.length) return null
|
||||
return (
|
||||
<div
|
||||
key={group}
|
||||
ref={el => { if (el) groupRefs.current.set(group, el) }}
|
||||
>
|
||||
<div key={group}>
|
||||
<div className="sticky top-0 z-10 bg-popover px-1 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{GROUP_SHORT_LABELS[group]}
|
||||
</div>
|
||||
|
||||
@@ -122,11 +122,11 @@ describe('Inspector', () => {
|
||||
expect(screen.getByText('Words')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders status as a colored pill', () => {
|
||||
it('renders status as a colored badge with dot indicator', () => {
|
||||
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
|
||||
const pill = screen.getByText('Active')
|
||||
// Status is rendered as an inline pill with CSS variable-based styles
|
||||
expect(pill).toHaveStyle({ borderRadius: '16px' })
|
||||
const badge = screen.getByTestId('status-badge')
|
||||
expect(badge).toHaveTextContent('Active')
|
||||
expect(badge.className).toContain('rounded')
|
||||
})
|
||||
|
||||
it('computes word count from content minus frontmatter and title', () => {
|
||||
@@ -137,7 +137,7 @@ describe('Inspector', () => {
|
||||
|
||||
it('shows "Add property" button as disabled placeholder', () => {
|
||||
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
|
||||
const btn = screen.getByText('+ Add property')
|
||||
const btn = screen.getByText('Add property')
|
||||
expect(btn).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -188,10 +188,7 @@ This is a test note with some words to count.
|
||||
entries={[mockEntry, referrerEntry]}
|
||||
/>
|
||||
)
|
||||
// Backlinks section is collapsed by default, but header with count is visible
|
||||
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
|
||||
// Expand to see the backlink entry
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Backlinks')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -204,10 +201,8 @@ This is a test note with some words to count.
|
||||
entries={[mockEntry, { ...referrerEntry, outgoingLinks: [] }]}
|
||||
/>
|
||||
)
|
||||
// Initially no backlinks — section is hidden entirely
|
||||
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
|
||||
|
||||
// Rerender with updated outgoingLinks (simulates adding [[Test Project]] to referrer)
|
||||
rerender(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
@@ -216,8 +211,7 @@ This is a test note with some words to count.
|
||||
entries={[mockEntry, { ...referrerEntry, outgoingLinks: ['Test Project'] }]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Backlinks (1)')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Backlinks')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referrer Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -230,7 +224,7 @@ This is a test note with some words to count.
|
||||
entries={[mockEntry]}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('backlinks-toggle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when a backlink is clicked', () => {
|
||||
@@ -244,7 +238,6 @@ This is a test note with some words to count.
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
fireEvent.click(screen.getByText('Referrer Note'))
|
||||
expect(onNavigate).toHaveBeenCalledWith('Referrer Note')
|
||||
})
|
||||
@@ -260,12 +253,11 @@ This is a test note with some words to count.
|
||||
)
|
||||
expect(screen.getByText('History')).toBeInTheDocument()
|
||||
expect(screen.getByText('a1b2c3d')).toBeInTheDocument()
|
||||
expect(screen.getByText('Update test with latest changes')).toBeInTheDocument()
|
||||
expect(screen.getByText('e4f5g6h')).toBeInTheDocument()
|
||||
expect(screen.getByText('i7j8k9l')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders commit hashes as clickable buttons', () => {
|
||||
it('renders commit entries as clickable buttons', () => {
|
||||
const onViewCommitDiff = vi.fn()
|
||||
render(
|
||||
<Inspector
|
||||
@@ -277,25 +269,13 @@ This is a test note with some words to count.
|
||||
/>
|
||||
)
|
||||
const hashBtn = screen.getByText('a1b2c3d')
|
||||
expect(hashBtn.tagName).toBe('BUTTON')
|
||||
hashBtn.click()
|
||||
const button = hashBtn.closest('button')!
|
||||
expect(button.tagName).toBe('BUTTON')
|
||||
button.click()
|
||||
expect(onViewCommitDiff).toHaveBeenCalledWith('a1b2c3d4e5f6a7b8')
|
||||
})
|
||||
|
||||
it('shows author name in commit rows', () => {
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={mockEntry}
|
||||
content={mockContent}
|
||||
gitHistory={mockGitHistory}
|
||||
/>
|
||||
)
|
||||
const authors = screen.getAllByText('Luca Rossi')
|
||||
expect(authors.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows "No revision history" when no commits', () => {
|
||||
it('hides history section when no commits', () => {
|
||||
render(
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
@@ -304,7 +284,7 @@ This is a test note with some words to count.
|
||||
gitHistory={[]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('No revision history')).toBeInTheDocument()
|
||||
expect(screen.queryByText('History')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows separate Info section with read-only metadata', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SlidersHorizontal, X } from '@phosphor-icons/react'
|
||||
import { Separator } from './ui/separator'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||
@@ -89,16 +90,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>
|
||||
</>
|
||||
@@ -162,7 +163,9 @@ export function Inspector({
|
||||
/>
|
||||
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<BacklinksPanel backlinks={backlinks} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
{backlinks.length > 0 && <Separator />}
|
||||
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
|
||||
{gitHistory.length > 0 && <Separator />}
|
||||
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -632,7 +632,7 @@ describe('BacklinksPanel', () => {
|
||||
})
|
||||
|
||||
it('renders nothing when empty', () => {
|
||||
const { container } = render(<BacklinksPanel typeEntryMap={{}} backlinks={[]} onNavigate={onNavigate} />)
|
||||
const { container } = render(<BacklinksPanel backlinks={[]} onNavigate={onNavigate} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
@@ -641,23 +641,16 @@ describe('BacklinksPanel', () => {
|
||||
{ entry: makeEntry({ title: 'Another Note', isA: 'Project', path: '/vault/project/another.md' }), context: null },
|
||||
]
|
||||
|
||||
it('renders collapsed by default with count badge', () => {
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
|
||||
expect(screen.getByText('Backlinks (2)')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Referencing Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands to show backlink entries when toggle clicked', () => {
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={twoBacklinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
it('renders header and all backlinks immediately (no collapse)', () => {
|
||||
render(<BacklinksPanel backlinks={twoBacklinks} onNavigate={onNavigate} />)
|
||||
expect(screen.getByText('Backlinks')).toBeInTheDocument()
|
||||
expect(screen.getByText('Referencing Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('Another Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when clicking backlink', () => {
|
||||
const backlinks = [{ entry: makeEntry({ title: 'Reference' }), context: null }]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByText('Reference'))
|
||||
expect(onNavigate).toHaveBeenCalledWith('Reference')
|
||||
})
|
||||
@@ -666,39 +659,26 @@ describe('BacklinksPanel', () => {
|
||||
const backlinks = [
|
||||
{ entry: makeEntry({ title: 'Referencing Note' }), context: 'This references [[My Note]] in context.' },
|
||||
]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
expect(screen.getByText('This references [[My Note]] in context.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses when toggle clicked twice', () => {
|
||||
const backlinks = [{ entry: makeEntry({ title: 'Note A' }), context: null }]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.getByText('Note A')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
expect(screen.queryByText('Note A')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows emoji icon before backlink title when entry has an emoji', () => {
|
||||
const backlinks = [{
|
||||
entry: makeEntry({ title: 'Starred Note', icon: '⭐' }),
|
||||
context: null,
|
||||
}]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
expect(screen.getByText('⭐')).toBeInTheDocument()
|
||||
expect(screen.getByText('Starred Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show emoji when backlink entry has no icon', () => {
|
||||
const backlinks = [{ entry: makeEntry({ title: 'Plain Note' }), context: null }]
|
||||
render(<BacklinksPanel typeEntryMap={{}} backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
fireEvent.click(screen.getByTestId('backlinks-toggle'))
|
||||
render(<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />)
|
||||
expect(screen.getByText('Plain Note')).toBeInTheDocument()
|
||||
const btn = screen.getByText('Plain Note').closest('button')
|
||||
const spans = btn?.querySelectorAll('span.shrink-0')
|
||||
// No emoji span should exist (only possible shrink-0 spans are trash icon, not emoji)
|
||||
const emojiSpans = Array.from(spans ?? []).filter(s => /[\p{Emoji_Presentation}]/u.test(s.textContent ?? ''))
|
||||
expect(emojiSpans).toHaveLength(0)
|
||||
})
|
||||
@@ -764,12 +744,12 @@ describe('GitHistoryPanel', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows "No revision history" when empty', () => {
|
||||
render(<GitHistoryPanel commits={[]} />)
|
||||
expect(screen.getByText('No revision history')).toBeInTheDocument()
|
||||
it('renders nothing when empty', () => {
|
||||
const { container } = render(<GitHistoryPanel commits={[]} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders commit entries', () => {
|
||||
it('renders commit entries with hash and message', () => {
|
||||
const commits: GitCommit[] = [
|
||||
{ hash: 'abc1234567890', shortHash: 'abc1234', message: 'Initial commit', author: 'luca', date: Math.floor(Date.now() / 1000) - 3600 },
|
||||
{ hash: 'def4567890123', shortHash: 'def4567', message: 'Fix bug', author: 'jane', date: Math.floor(Date.now() / 1000) - 86400 * 2 },
|
||||
@@ -777,13 +757,9 @@ describe('GitHistoryPanel', () => {
|
||||
render(<GitHistoryPanel commits={commits} onViewCommitDiff={onViewCommitDiff} />)
|
||||
expect(screen.getByText('abc1234')).toBeInTheDocument()
|
||||
expect(screen.getByText('def4567')).toBeInTheDocument()
|
||||
expect(screen.getByText('Initial commit')).toBeInTheDocument()
|
||||
expect(screen.getByText('Fix bug')).toBeInTheDocument()
|
||||
expect(screen.getByText('luca')).toBeInTheDocument()
|
||||
expect(screen.getByText('jane')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onViewCommitDiff when clicking commit hash', () => {
|
||||
it('calls onViewCommitDiff when clicking commit entry', () => {
|
||||
const commits: GitCommit[] = [
|
||||
{ hash: 'abc1234567890', shortHash: 'abc1234', message: 'test', author: '', date: Math.floor(Date.now() / 1000) },
|
||||
]
|
||||
|
||||
@@ -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}` })),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EditableValue, TagPillList, UrlValue } from './EditableValue'
|
||||
import { isUrlValue } from '../utils/url'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { CalendarIcon, XIcon } from 'lucide-react'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { isValidCssColor } from '../utils/colorUtils'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
DISPLAY_MODE_OPTIONS,
|
||||
DISPLAY_MODE_ICONS,
|
||||
} from '../utils/propertyTypes'
|
||||
import { StatusPill, StatusDropdown } from './StatusDropdown'
|
||||
import { StatusDropdown } from './StatusDropdown'
|
||||
import { getStatusStyle } from '../utils/statusStyles'
|
||||
import { TagsDropdown } from './TagsDropdown'
|
||||
import { getTagStyle } from '../utils/tagStyles'
|
||||
import { ColorEditableValue } from './ColorInput'
|
||||
@@ -37,14 +38,17 @@ function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStart
|
||||
onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void
|
||||
}) {
|
||||
const statusStr = String(value)
|
||||
const style = getStatusStyle(statusStr)
|
||||
return (
|
||||
<span className="relative inline-flex min-w-0 items-center">
|
||||
<span
|
||||
className="cursor-pointer transition-opacity hover:opacity-80"
|
||||
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"
|
||||
style={{ backgroundColor: style.bg, color: style.color }}
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
data-testid="status-badge"
|
||||
>
|
||||
<StatusPill status={statusStr} />
|
||||
<span className="inline-block size-1.5 shrink-0 rounded-full" style={{ backgroundColor: style.color }} />
|
||||
{statusStr}
|
||||
</span>
|
||||
{isEditing && (
|
||||
<StatusDropdown
|
||||
@@ -79,18 +83,15 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className="group/tag relative inline-flex items-center overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: style.bg, padding: '1px 6px', maxWidth: 120 }}
|
||||
className="group/tag relative inline-flex items-center overflow-hidden rounded-md"
|
||||
style={{ backgroundColor: style.bg, padding: '4px 8px', maxWidth: 120 }}
|
||||
>
|
||||
<span
|
||||
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
|
||||
style={{
|
||||
color: style.color,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0',
|
||||
textTransform: 'uppercase' as const,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}}
|
||||
@@ -99,7 +100,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
|
||||
</span>
|
||||
<button
|
||||
className="ml-0.5 max-w-0 overflow-hidden border-none bg-transparent p-0 leading-none opacity-0 transition-all duration-150 group-hover/tag:max-w-[14px] group-hover/tag:opacity-100"
|
||||
style={{ color: style.color, fontSize: 10, flexShrink: 0 }}
|
||||
style={{ color: style.color, fontSize: 11, flexShrink: 0 }}
|
||||
onClick={() => handleRemove(tag)}
|
||||
title={`Remove ${tag}`}
|
||||
>
|
||||
@@ -109,7 +110,8 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
|
||||
)
|
||||
})}
|
||||
<button
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-dashed border-muted-foreground bg-transparent text-[10px] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
|
||||
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: '4px 8px' }}
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
title="Add tag"
|
||||
data-testid="tags-add-button"
|
||||
@@ -128,13 +130,15 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
|
||||
|
||||
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onToggle}
|
||||
data-testid="boolean-toggle"
|
||||
>
|
||||
{value ? '\u2713 Yes' : '\u2717 No'}
|
||||
</button>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5" data-testid="boolean-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={onToggle}
|
||||
className="size-3.5 cursor-pointer accent-primary"
|
||||
/>
|
||||
<span className="text-[12px] text-secondary-foreground">{value ? 'Yes' : 'No'}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -160,11 +164,10 @@ function DateValue({ value, onSave }: {
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="inline-flex min-w-0 cursor-pointer items-center gap-1 rounded border-none bg-transparent px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
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'}`}
|
||||
title={value}
|
||||
data-testid="date-display"
|
||||
>
|
||||
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ''}`}>{formatted || 'Pick a date\u2026'}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -167,7 +167,7 @@ function DayGroup({ label, commits, onOpenNote }: {
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
<Chevron size={12} className="text-muted-foreground" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="-ml-1 w-1 shrink-0 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', () => {
|
||||
|
||||
@@ -12,9 +12,9 @@ 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,
|
||||
@@ -33,11 +33,8 @@ interface SidebarProps {
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
onToggleTypeVisibility?: (typeName: string) => void
|
||||
modifiedCount?: number
|
||||
inboxCount?: number
|
||||
onCommitPush?: () => void
|
||||
onCollapse?: () => void
|
||||
isGitVault?: boolean
|
||||
}
|
||||
|
||||
// --- Hooks ---
|
||||
@@ -123,7 +120,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 +137,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 +205,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,
|
||||
inboxCount = 0, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -315,7 +297,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
{/* Sections header + visibility popover */}
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px 0' }}>
|
||||
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Sections</span>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">Sections</span>
|
||||
<button className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground" style={{ width: 20, height: 20 }} onClick={() => setShowCustomize((v) => !v)} aria-label="Customize sections" title="Customize sections">
|
||||
<SlidersHorizontal size={14} />
|
||||
</button>
|
||||
@@ -333,14 +315,6 @@ export const Sidebar = memo(function Sidebar({
|
||||
</DndContext>
|
||||
</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>
|
||||
|
||||
@@ -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)
|
||||
@@ -406,7 +453,8 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
<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} />
|
||||
<ChangesBadge count={modifiedCount} onClick={onClickPending} onCommitPush={onCommitPush} />
|
||||
<PulseBadge onClick={onClickPulse} disabled={!isGitVault} />
|
||||
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
|
||||
@@ -13,11 +13,10 @@ export function StatusPill({ status, className }: { status: string; className?:
|
||||
color: style.color,
|
||||
borderRadius: 16,
|
||||
padding: '1px 6px',
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0',
|
||||
textTransform: 'uppercase' as const,
|
||||
maxWidth: 160,
|
||||
}}
|
||||
title={status}
|
||||
@@ -98,11 +97,10 @@ function StatusOption({
|
||||
}
|
||||
|
||||
const SECTION_LABEL_STYLE = {
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontSize: 9,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0',
|
||||
textTransform: 'uppercase' as const,
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
|
||||
@@ -10,11 +10,11 @@ describe('TagPill', () => {
|
||||
expect(pill.textContent).toBe('React')
|
||||
})
|
||||
|
||||
it('renders with default style (blue)', () => {
|
||||
it('renders with a hash-based accent color', () => {
|
||||
render(<TagPill tag="Unknown" />)
|
||||
const pill = screen.getByTitle('Unknown')
|
||||
expect(pill.style.backgroundColor).toBe('var(--accent-blue-light)')
|
||||
expect(pill.style.color).toBe('var(--accent-blue)')
|
||||
expect(pill.style.backgroundColor).toMatch(/^var\(--accent-\w+-light\)$/)
|
||||
expect(pill.style.color).toMatch(/^var\(--accent-\w+\)$/)
|
||||
})
|
||||
|
||||
it('applies truncate for long names', () => {
|
||||
|
||||
@@ -13,11 +13,10 @@ export function TagPill({ tag, className }: { tag: string; className?: string })
|
||||
color: style.color,
|
||||
borderRadius: 16,
|
||||
padding: '1px 6px',
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0',
|
||||
maxWidth: 160,
|
||||
}}
|
||||
title={tag}
|
||||
@@ -90,11 +89,10 @@ function TagOption({
|
||||
}
|
||||
|
||||
const SECTION_LABEL_STYLE = {
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontSize: 9,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0',
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
|
||||
@@ -66,9 +66,9 @@ describe('TypeCustomizePopover', () => {
|
||||
|
||||
it('renders color, icon, and template sections', () => {
|
||||
renderPopover()
|
||||
expect(screen.getByText('COLOR')).toBeInTheDocument()
|
||||
expect(screen.getByText('ICON')).toBeInTheDocument()
|
||||
expect(screen.getByText('TEMPLATE')).toBeInTheDocument()
|
||||
expect(screen.getByText('Color')).toBeInTheDocument()
|
||||
expect(screen.getByText('Icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Template')).toBeInTheDocument()
|
||||
expect(screen.getByText('Done')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export function TypeCustomizePopover({
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Color section */}
|
||||
<div className="font-mono-overline mb-2 text-muted-foreground">COLOR</div>
|
||||
<div className="font-mono-overline mb-2 text-muted-foreground">Color</div>
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{ACCENT_COLORS.map((c) => (
|
||||
<button
|
||||
@@ -92,7 +92,7 @@ export function TypeCustomizePopover({
|
||||
</div>
|
||||
|
||||
{/* Icon section */}
|
||||
<div className="font-mono-overline mb-2 text-muted-foreground">ICON</div>
|
||||
<div className="font-mono-overline mb-2 text-muted-foreground">Icon</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative mb-2">
|
||||
@@ -136,7 +136,7 @@ export function TypeCustomizePopover({
|
||||
</div>
|
||||
|
||||
{/* Template section */}
|
||||
<div className="font-mono-overline mb-2 mt-3 text-muted-foreground">TEMPLATE</div>
|
||||
<div className="font-mono-overline mb-2 mt-3 text-muted-foreground">Template</div>
|
||||
<textarea
|
||||
value={templateText}
|
||||
onChange={(e) => handleTemplateChange(e.target.value)}
|
||||
|
||||
@@ -23,7 +23,7 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
|
||||
if (!isA) return null
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
<span className="text-[12px] shrink-0 text-muted-foreground">Type</span>
|
||||
<div className="min-w-0">
|
||||
{onNavigate ? (
|
||||
<button
|
||||
@@ -58,7 +58,7 @@ export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKey
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="type-selector">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
<span className="text-[12px] shrink-0 text-muted-foreground">Type</span>
|
||||
<div className="min-w-0">
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { CaretRight, Trash } from '@phosphor-icons/react'
|
||||
import { getTypeColor } from '../../utils/typeColors'
|
||||
import { ArrowUpRight, Trash } from '@phosphor-icons/react'
|
||||
import { isEmoji } from '../../utils/emoji'
|
||||
import { entryStatusTitle } from './shared'
|
||||
import { StatusSuffix } from './LinkButton'
|
||||
@@ -11,23 +9,21 @@ export interface BacklinkItem {
|
||||
context: string | null
|
||||
}
|
||||
|
||||
function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
|
||||
function BacklinkEntry({ entry, context, onNavigate }: {
|
||||
entry: VaultEntry
|
||||
context: string | null
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const isDimmed = entry.archived || entry.trashed
|
||||
return (
|
||||
<button
|
||||
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:opacity-80"
|
||||
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:underline"
|
||||
onClick={() => onNavigate(entry.title)}
|
||||
title={entryStatusTitle(entry)}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1 text-xs font-medium"
|
||||
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
|
||||
className="flex items-center gap-1 text-xs text-primary"
|
||||
style={isDimmed ? { color: 'var(--muted-foreground)' } : undefined}
|
||||
>
|
||||
{entry.trashed && <Trash size={12} className="shrink-0" />}
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="shrink-0">{entry.icon}</span>}
|
||||
@@ -43,42 +39,28 @@ function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: {
|
||||
export function BacklinksPanel({ backlinks, onNavigate }: {
|
||||
backlinks: BacklinkItem[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (backlinks.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="font-mono-overline mb-2 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
data-testid="backlinks-toggle"
|
||||
>
|
||||
<CaretRight
|
||||
size={12}
|
||||
className="shrink-0 transition-transform"
|
||||
style={{ transform: expanded ? 'rotate(90deg)' : undefined }}
|
||||
/>
|
||||
Backlinks ({backlinks.length})
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
|
||||
{backlinks.map(({ entry, context }) => (
|
||||
<BacklinkEntry
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
context={context}
|
||||
typeEntryMap={typeEntryMap}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h4 className="font-mono-overline mb-2 flex items-center gap-1 text-muted-foreground">
|
||||
<ArrowUpRight size={12} className="shrink-0" />
|
||||
Backlinks
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
|
||||
{backlinks.map(({ entry, context }) => (
|
||||
<BacklinkEntry
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
context={context}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ArrowCounterClockwise } from '@phosphor-icons/react'
|
||||
import type { GitCommit } from '../../types'
|
||||
|
||||
function formatRelativeDate(timestamp: number): string {
|
||||
@@ -11,26 +12,32 @@ function formatRelativeDate(timestamp: number): string {
|
||||
}
|
||||
|
||||
export function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) {
|
||||
if (commits.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="font-mono-overline mb-2 text-muted-foreground">History</h4>
|
||||
{commits.length === 0
|
||||
? <p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
|
||||
: (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{commits.map((c) => (
|
||||
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
|
||||
<div className="mb-0.5 flex items-center justify-between">
|
||||
<button className="border-none bg-transparent p-0 font-mono text-primary cursor-pointer hover:underline" style={{ fontSize: 11 }} onClick={() => onViewCommitDiff?.(c.hash)} title={`View diff for ${c.shortHash}`}>{c.shortHash}</button>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 10 }}>{formatRelativeDate(c.date)}</span>
|
||||
</div>
|
||||
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
|
||||
{c.author && <div className="truncate text-muted-foreground" style={{ fontSize: 10, marginTop: 1 }}>{c.author}</div>}
|
||||
</div>
|
||||
))}
|
||||
<h4 className="font-mono-overline mb-2 flex items-center gap-1 text-muted-foreground">
|
||||
<ArrowCounterClockwise size={12} className="shrink-0" />
|
||||
History
|
||||
</h4>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{commits.map((c) => (
|
||||
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
|
||||
<button
|
||||
className="mb-0.5 w-full cursor-pointer truncate border-none bg-transparent p-0 text-left text-xs text-primary hover:underline"
|
||||
onClick={() => onViewCommitDiff?.(c.hash)}
|
||||
title={`View diff for ${c.shortHash}`}
|
||||
>
|
||||
<span className="font-mono" style={{ fontSize: 11 }}>{c.shortHash}</span>
|
||||
{' · '}
|
||||
{c.message}
|
||||
</button>
|
||||
<div className="text-muted-foreground" style={{ fontSize: 10 }}>
|
||||
{formatRelativeDate(c.date)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{grouped.map(([viaKey, groupEntries]) => (
|
||||
<div key={viaKey}>
|
||||
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
|
||||
<span className="mb-1 block text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '0.02em', opacity: 0.7 }}>
|
||||
← {viaKey}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
|
||||
@@ -8,10 +8,10 @@ interface InboxFilterPillsProps {
|
||||
}
|
||||
|
||||
const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
{ value: 'week', label: 'This week' },
|
||||
{ value: 'month', label: 'This month' },
|
||||
{ value: 'quarter', label: 'This quarter' },
|
||||
{ value: 'all', label: 'All time' },
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'quarter', label: 'Quarter' },
|
||||
{ value: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {
|
||||
|
||||
17
src/hooks/commands/filterCommands.ts
Normal file
17
src/hooks/commands/filterCommands.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { CommandAction } from './types'
|
||||
import type { NoteListFilter } from '../../utils/noteListHelpers'
|
||||
|
||||
interface FilterCommandsConfig {
|
||||
isSectionGroup: boolean
|
||||
noteListFilter?: NoteListFilter
|
||||
onSetNoteListFilter?: (filter: NoteListFilter) => void
|
||||
}
|
||||
|
||||
export function buildFilterCommands(config: FilterCommandsConfig): CommandAction[] {
|
||||
const { isSectionGroup, noteListFilter, onSetNoteListFilter } = config
|
||||
return [
|
||||
{ id: 'filter-open', label: 'Show Open Notes', group: 'Navigation', keywords: ['filter', 'open', 'active', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'open', execute: () => onSetNoteListFilter?.('open') },
|
||||
{ id: 'filter-archived', label: 'Show Archived Notes', group: 'Navigation', keywords: ['filter', 'archived', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'archived', execute: () => onSetNoteListFilter?.('archived') },
|
||||
{ id: 'filter-trashed', label: 'Show Trashed Notes', group: 'Navigation', keywords: ['filter', 'trashed', 'trash', 'pill', 'deleted'], enabled: !!isSectionGroup && noteListFilter !== 'trashed', execute: () => onSetNoteListFilter?.('trashed') },
|
||||
]
|
||||
}
|
||||
20
src/hooks/commands/gitCommands.ts
Normal file
20
src/hooks/commands/gitCommands.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CommandAction } from './types'
|
||||
import type { SidebarSelection } from '../../types'
|
||||
|
||||
interface GitCommandsConfig {
|
||||
modifiedCount: number
|
||||
onCommitPush: () => void
|
||||
onPull?: () => void
|
||||
onResolveConflicts?: () => void
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
}
|
||||
|
||||
export function buildGitCommands(config: GitCommandsConfig): CommandAction[] {
|
||||
const { modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect } = config
|
||||
return [
|
||||
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
|
||||
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
|
||||
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() },
|
||||
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
]
|
||||
}
|
||||
9
src/hooks/commands/index.ts
Normal file
9
src/hooks/commands/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type { CommandAction, CommandGroup } from './types'
|
||||
export { groupSortKey } from './types'
|
||||
export { buildNavigationCommands } from './navigationCommands'
|
||||
export { buildNoteCommands } from './noteCommands'
|
||||
export { buildGitCommands } from './gitCommands'
|
||||
export { buildViewCommands } from './viewCommands'
|
||||
export { buildSettingsCommands } from './settingsCommands'
|
||||
export { buildTypeCommands, extractVaultTypes, pluralizeType } from './typeCommands'
|
||||
export { buildFilterCommands } from './filterCommands'
|
||||
27
src/hooks/commands/navigationCommands.ts
Normal file
27
src/hooks/commands/navigationCommands.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { CommandAction } from './types'
|
||||
import type { SidebarSelection } from '../../types'
|
||||
|
||||
interface NavigationCommandsConfig {
|
||||
onQuickOpen: () => void
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onOpenDailyNote: () => void
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
}
|
||||
|
||||
export function buildNavigationCommands(config: NavigationCommandsConfig): CommandAction[] {
|
||||
const { onQuickOpen, onSelect, onGoBack, onGoForward, canGoBack, canGoForward } = config
|
||||
return [
|
||||
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
|
||||
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
|
||||
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
|
||||
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
|
||||
{ id: 'go-inbox', label: 'Go to Inbox', group: 'Navigation', keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'inbox' }) },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
|
||||
]
|
||||
}
|
||||
69
src/hooks/commands/noteCommands.ts
Normal file
69
src/hooks/commands/noteCommands.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { CommandAction } from './types'
|
||||
|
||||
interface NoteCommandsConfig {
|
||||
hasActiveNote: boolean
|
||||
activeTabPath: string | null
|
||||
isArchived: boolean
|
||||
isTrashed: boolean
|
||||
activeNoteHasIcon?: boolean
|
||||
trashedCount?: number
|
||||
onCreateNote: () => void
|
||||
onCreateType?: () => void
|
||||
onOpenDailyNote: () => void
|
||||
onSave: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
onRestoreNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onEmptyTrash?: () => void
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
}
|
||||
|
||||
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
const {
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed,
|
||||
onCreateNote, onCreateType, onOpenDailyNote, onSave,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onEmptyTrash, trashedCount,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onOpenInNewWindow,
|
||||
} = config
|
||||
|
||||
return [
|
||||
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
|
||||
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
|
||||
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
|
||||
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
|
||||
{ id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
|
||||
{
|
||||
id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫',
|
||||
keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isTrashed ? onRestoreNote : onTrashNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
|
||||
enabled: hasActiveNote && !!onSetNoteIcon,
|
||||
execute: () => onSetNoteIcon?.(),
|
||||
},
|
||||
{
|
||||
id: 'remove-note-icon', label: 'Remove Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
|
||||
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
|
||||
execute: () => onRemoveNoteIcon?.(),
|
||||
},
|
||||
{
|
||||
id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O',
|
||||
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
|
||||
enabled: hasActiveNote,
|
||||
execute: () => onOpenInNewWindow?.(),
|
||||
},
|
||||
]
|
||||
}
|
||||
34
src/hooks/commands/settingsCommands.ts
Normal file
34
src/hooks/commands/settingsCommands.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { CommandAction } from './types'
|
||||
|
||||
interface SettingsCommandsConfig {
|
||||
mcpStatus?: string
|
||||
vaultCount?: number
|
||||
isGettingStartedHidden?: boolean
|
||||
onOpenSettings: () => void
|
||||
onOpenVault?: () => void
|
||||
onRemoveActiveVault?: () => void
|
||||
onRestoreGettingStarted?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
onInstallMcp?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
}
|
||||
|
||||
export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] {
|
||||
const {
|
||||
mcpStatus, vaultCount, isGettingStartedHidden,
|
||||
onOpenSettings, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
|
||||
} = config
|
||||
|
||||
return [
|
||||
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
|
||||
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
|
||||
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
|
||||
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
|
||||
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
|
||||
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
|
||||
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
|
||||
]
|
||||
}
|
||||
48
src/hooks/commands/typeCommands.ts
Normal file
48
src/hooks/commands/typeCommands.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { CommandAction } from './types'
|
||||
import type { SidebarSelection, VaultEntry } from '../../types'
|
||||
|
||||
const PLURAL_OVERRIDES: Record<string, string> = {
|
||||
Person: 'People',
|
||||
Responsibility: 'Responsibilities',
|
||||
}
|
||||
|
||||
const DEFAULT_TYPES = ['Event', 'Person', 'Project', 'Note']
|
||||
|
||||
export function pluralizeType(type: string): string {
|
||||
if (PLURAL_OVERRIDES[type]) return PLURAL_OVERRIDES[type]
|
||||
if (type.endsWith('s') || type.endsWith('x') || type.endsWith('ch') || type.endsWith('sh')) return `${type}es`
|
||||
if (type.endsWith('y') && !/[aeiou]y$/i.test(type)) return `${type.slice(0, -1)}ies`
|
||||
return `${type}s`
|
||||
}
|
||||
|
||||
export function extractVaultTypes(entries: VaultEntry[]): string[] {
|
||||
const typeSet = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (e.isA && e.isA !== 'Type' && !e.trashed) typeSet.add(e.isA)
|
||||
}
|
||||
if (typeSet.size === 0) return DEFAULT_TYPES
|
||||
return Array.from(typeSet).sort()
|
||||
}
|
||||
|
||||
export function buildTypeCommands(
|
||||
types: string[],
|
||||
onCreateNoteOfType: (type: string) => void,
|
||||
onSelect: (sel: SidebarSelection) => void,
|
||||
): CommandAction[] {
|
||||
return types.flatMap((type) => {
|
||||
const slug = type.toLowerCase().replace(/\s+/g, '-')
|
||||
const plural = pluralizeType(type)
|
||||
return [
|
||||
{
|
||||
id: `new-${slug}`, label: `New ${type}`, group: 'Note' as const,
|
||||
keywords: ['new', 'create', type.toLowerCase()],
|
||||
enabled: true, execute: () => onCreateNoteOfType(type),
|
||||
},
|
||||
{
|
||||
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as const,
|
||||
keywords: ['list', 'show', 'filter', type.toLowerCase(), plural.toLowerCase()],
|
||||
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type }),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
17
src/hooks/commands/types.ts
Normal file
17
src/hooks/commands/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Settings'
|
||||
|
||||
export interface CommandAction {
|
||||
id: string
|
||||
label: string
|
||||
group: CommandGroup
|
||||
shortcut?: string
|
||||
keywords?: string[]
|
||||
enabled: boolean
|
||||
execute: () => void
|
||||
}
|
||||
|
||||
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings']
|
||||
|
||||
export function groupSortKey(group: CommandGroup): number {
|
||||
return GROUP_ORDER.indexOf(group)
|
||||
}
|
||||
38
src/hooks/commands/viewCommands.ts
Normal file
38
src/hooks/commands/viewCommands.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { CommandAction } from './types'
|
||||
import type { ViewMode } from '../useViewMode'
|
||||
|
||||
interface ViewCommandsConfig {
|
||||
hasActiveNote: boolean
|
||||
activeNoteModified: boolean
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleDiff?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
zoomLevel: number
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
}
|
||||
|
||||
export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
const {
|
||||
hasActiveNote, activeNoteModified,
|
||||
onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat,
|
||||
zoomLevel, onZoomIn, onZoomOut, onZoomReset,
|
||||
} = config
|
||||
|
||||
return [
|
||||
{ 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', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
]
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -2,24 +2,24 @@ import { useMemo } from 'react'
|
||||
import type { SidebarSelection, VaultEntry } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
import { buildNavigationCommands } from './commands/navigationCommands'
|
||||
import { buildNoteCommands } from './commands/noteCommands'
|
||||
import { buildGitCommands } from './commands/gitCommands'
|
||||
import { buildViewCommands } from './commands/viewCommands'
|
||||
import { buildSettingsCommands } from './commands/settingsCommands'
|
||||
import { buildTypeCommands, extractVaultTypes } from './commands/typeCommands'
|
||||
import { buildFilterCommands } from './commands/filterCommands'
|
||||
|
||||
export type CommandGroup = 'Navigation' | 'Note' | 'Git' | 'View' | 'Settings'
|
||||
|
||||
export interface CommandAction {
|
||||
id: string
|
||||
label: string
|
||||
group: CommandGroup
|
||||
shortcut?: string
|
||||
keywords?: string[]
|
||||
enabled: boolean
|
||||
execute: () => void
|
||||
}
|
||||
// Re-export types and helpers for backward compatibility
|
||||
export type { CommandAction, CommandGroup } from './commands/types'
|
||||
export { groupSortKey } from './commands/types'
|
||||
export { pluralizeType, extractVaultTypes, buildTypeCommands } from './commands/typeCommands'
|
||||
export { buildViewCommands } from './commands/viewCommands'
|
||||
|
||||
interface CommandRegistryConfig {
|
||||
activeTabPath: string | null
|
||||
entries: VaultEntry[]
|
||||
modifiedCount: number
|
||||
/** Whether the active note has an emoji icon set. */
|
||||
activeNoteHasIcon?: boolean
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
@@ -30,7 +30,6 @@ interface CommandRegistryConfig {
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
onCreateNoteOfType: (type: string) => void
|
||||
@@ -66,93 +65,12 @@ interface CommandRegistryConfig {
|
||||
onRestoreGettingStarted?: () => void
|
||||
isGettingStartedHidden?: boolean
|
||||
vaultCount?: number
|
||||
/** Current selection — used to scope filter pill commands to section group views. */
|
||||
selection?: SidebarSelection
|
||||
noteListFilter?: NoteListFilter
|
||||
onSetNoteListFilter?: (filter: NoteListFilter) => void
|
||||
}
|
||||
|
||||
const PLURAL_OVERRIDES: Record<string, string> = {
|
||||
Person: 'People',
|
||||
Responsibility: 'Responsibilities',
|
||||
}
|
||||
|
||||
const DEFAULT_TYPES = ['Event', 'Person', 'Project', 'Note']
|
||||
|
||||
export function pluralizeType(type: string): string {
|
||||
if (PLURAL_OVERRIDES[type]) return PLURAL_OVERRIDES[type]
|
||||
if (type.endsWith('s') || type.endsWith('x') || type.endsWith('ch') || type.endsWith('sh')) return `${type}es`
|
||||
if (type.endsWith('y') && !/[aeiou]y$/i.test(type)) return `${type.slice(0, -1)}ies`
|
||||
return `${type}s`
|
||||
}
|
||||
|
||||
export function extractVaultTypes(entries: VaultEntry[]): string[] {
|
||||
const typeSet = new Set<string>()
|
||||
for (const e of entries) {
|
||||
if (e.isA && e.isA !== 'Type' && !e.trashed) typeSet.add(e.isA)
|
||||
}
|
||||
if (typeSet.size === 0) return DEFAULT_TYPES
|
||||
return Array.from(typeSet).sort()
|
||||
}
|
||||
|
||||
const GROUP_ORDER: CommandGroup[] = ['Navigation', 'Note', 'Git', 'View', 'Settings']
|
||||
|
||||
export function groupSortKey(group: CommandGroup): number {
|
||||
return GROUP_ORDER.indexOf(group)
|
||||
}
|
||||
|
||||
export function buildTypeCommands(
|
||||
types: string[],
|
||||
onCreateNoteOfType: (type: string) => void,
|
||||
onSelect: (sel: SidebarSelection) => void,
|
||||
): CommandAction[] {
|
||||
return types.flatMap((type) => {
|
||||
const slug = type.toLowerCase().replace(/\s+/g, '-')
|
||||
const plural = pluralizeType(type)
|
||||
return [
|
||||
{
|
||||
id: `new-${slug}`, label: `New ${type}`, group: 'Note' as CommandGroup,
|
||||
keywords: ['new', 'create', type.toLowerCase()],
|
||||
enabled: true, execute: () => onCreateNoteOfType(type),
|
||||
},
|
||||
{
|
||||
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as CommandGroup,
|
||||
keywords: ['list', 'show', 'filter', type.toLowerCase(), plural.toLowerCase()],
|
||||
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type }),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export function buildViewCommands(
|
||||
hasActiveNote: boolean,
|
||||
activeNoteModified: boolean,
|
||||
onSetViewMode: (mode: ViewMode) => void,
|
||||
onToggleInspector: () => void,
|
||||
onToggleDiff: (() => void) | undefined,
|
||||
onToggleRawEditor: (() => void) | undefined,
|
||||
onToggleAIChat: (() => void) | undefined,
|
||||
zoomLevel: number,
|
||||
onZoomIn: () => void,
|
||||
onZoomOut: () => void,
|
||||
onZoomReset: () => void,
|
||||
): CommandAction[] {
|
||||
return [
|
||||
{ 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-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
]
|
||||
}
|
||||
|
||||
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
|
||||
export function useCommandRegistry(config: CommandRegistryConfig): import('./commands/types').CommandAction[] {
|
||||
const {
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
@@ -162,21 +80,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
onCheckForUpdates,
|
||||
onCreateType,
|
||||
onCheckForUpdates, onCreateType,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onEmptyTrash, trashedCount,
|
||||
onReloadVault,
|
||||
onRepairVault,
|
||||
onSetNoteIcon,
|
||||
onRemoveNoteIcon,
|
||||
activeNoteHasIcon,
|
||||
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onOpenInNewWindow,
|
||||
selection, noteListFilter, onSetNoteListFilter,
|
||||
} = config
|
||||
|
||||
const isSectionGroup = selection?.kind === 'sectionGroup'
|
||||
const hasActiveNote = activeTabPath !== null
|
||||
|
||||
const activeEntry = useMemo(
|
||||
@@ -185,87 +97,31 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
)
|
||||
const isArchived = activeEntry?.archived ?? false
|
||||
const isTrashed = activeEntry?.trashed ?? false
|
||||
const isSectionGroup = selection?.kind === 'sectionGroup'
|
||||
|
||||
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
|
||||
|
||||
return useMemo(() => {
|
||||
const cmds: CommandAction[] = [
|
||||
// Navigation
|
||||
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
|
||||
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
|
||||
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
|
||||
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
|
||||
{ id: 'empty-trash', label: 'Empty Trash', group: 'Note', keywords: ['delete', 'permanently', 'purge', 'clear', 'trash'], enabled: (trashedCount ?? 0) > 0, execute: () => onEmptyTrash?.() },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
{ id: 'go-pulse', label: 'Go to Pulse', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
|
||||
{ id: 'go-inbox', label: 'Go to Inbox', group: 'Navigation', keywords: ['inbox', 'unlinked', 'orphan', 'unorganized', 'triage'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'inbox' }) },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘[', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘]', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
|
||||
|
||||
// Note actions (contextual)
|
||||
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
|
||||
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
|
||||
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
|
||||
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
|
||||
{
|
||||
id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫',
|
||||
keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isTrashed ? onRestoreNote : onTrashNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
|
||||
enabled: hasActiveNote && !!onSetNoteIcon,
|
||||
execute: () => onSetNoteIcon?.(),
|
||||
},
|
||||
{
|
||||
id: 'remove-note-icon', label: 'Remove Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'remove', 'delete', 'clear'],
|
||||
enabled: hasActiveNote && !!activeNoteHasIcon && !!onRemoveNoteIcon,
|
||||
execute: () => onRemoveNoteIcon?.(),
|
||||
},
|
||||
{
|
||||
id: 'open-in-new-window', label: 'Open in New Window', group: 'Note', shortcut: '⌘⇧O',
|
||||
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
|
||||
enabled: hasActiveNote,
|
||||
execute: () => onOpenInNewWindow?.(),
|
||||
},
|
||||
|
||||
// Git
|
||||
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
|
||||
{ id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() },
|
||||
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() },
|
||||
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
|
||||
// View
|
||||
...buildViewCommands(hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
|
||||
|
||||
// Settings
|
||||
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
|
||||
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
|
||||
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
|
||||
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
|
||||
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
|
||||
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
|
||||
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset', 'flatten', 'structure'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
|
||||
|
||||
// Type-aware: "New [Type]" and "List [Type]"
|
||||
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
|
||||
|
||||
// Note list filter pills (scoped to section group views)
|
||||
{ id: 'filter-open', label: 'Show Open Notes', group: 'Navigation', keywords: ['filter', 'open', 'active', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'open', execute: () => onSetNoteListFilter?.('open') },
|
||||
{ id: 'filter-archived', label: 'Show Archived Notes', group: 'Navigation', keywords: ['filter', 'archived', 'pill'], enabled: !!isSectionGroup && noteListFilter !== 'archived', execute: () => onSetNoteListFilter?.('archived') },
|
||||
{ id: 'filter-trashed', label: 'Show Trashed Notes', group: 'Navigation', keywords: ['filter', 'trashed', 'trash', 'pill', 'deleted'], enabled: !!isSectionGroup && noteListFilter !== 'trashed', execute: () => onSetNoteListFilter?.('trashed') },
|
||||
]
|
||||
|
||||
return cmds
|
||||
}, [
|
||||
return useMemo(() => [
|
||||
...buildNavigationCommands({ onQuickOpen, onSelect, onOpenDailyNote, onGoBack, onGoForward, canGoBack, canGoForward }),
|
||||
...buildNoteCommands({
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed,
|
||||
onCreateNote, onCreateType, onOpenDailyNote, onSave,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onEmptyTrash, trashedCount, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow,
|
||||
}),
|
||||
...buildGitCommands({ modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect }),
|
||||
...buildViewCommands({
|
||||
hasActiveNote, activeNoteModified, onSetViewMode, onToggleInspector,
|
||||
onToggleDiff, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset,
|
||||
}),
|
||||
...buildSettingsCommands({
|
||||
mcpStatus, vaultCount, isGettingStartedHidden,
|
||||
onOpenSettings, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
|
||||
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
|
||||
}),
|
||||
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
|
||||
...buildFilterCommands({ isSectionGroup, noteListFilter, onSetNoteListFilter }),
|
||||
], [
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -149,22 +149,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* --- IBM Plex Mono label styles (from typography scale) --- */
|
||||
/* --- Label typography (Inter, sentence case) --- */
|
||||
/* t8: Section labels, metadata tags — 11px medium */
|
||||
.font-mono-label {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* t9: Overlines, category labels — 10px regular */
|
||||
.font-mono-overline {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.referenced-by-panel button:hover {
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
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`
|
||||
}
|
||||
@@ -77,6 +77,14 @@ describe('detectPropertyType', () => {
|
||||
expect(detectPropertyType('custom_list', ['x', 'y'])).toBe('text')
|
||||
})
|
||||
|
||||
it('detects tags from tag-like key names even with scalar string values', () => {
|
||||
expect(detectPropertyType('tags', 'Has Pic')).toBe('tags')
|
||||
expect(detectPropertyType('Tags', 'solo-tag')).toBe('tags')
|
||||
expect(detectPropertyType('keywords', 'react')).toBe('tags')
|
||||
expect(detectPropertyType('categories', 'frontend')).toBe('tags')
|
||||
expect(detectPropertyType('labels', 'bug')).toBe('tags')
|
||||
})
|
||||
|
||||
it('treats date-keyed fields with non-date values as text', () => {
|
||||
expect(detectPropertyType('deadline', 'active')).toBe('text')
|
||||
})
|
||||
|
||||
@@ -38,7 +38,8 @@ function detectStringType(key: string, strValue: string): PropertyDisplayMode {
|
||||
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
|
||||
if (value === null || value === undefined) return 'text'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (Array.isArray(value)) return keyMatchesPatterns(key, TAGS_KEY_PATTERNS) ? 'tags' : 'text'
|
||||
if (keyMatchesPatterns(key, TAGS_KEY_PATTERNS)) return 'tags'
|
||||
if (Array.isArray(value)) return 'text'
|
||||
return detectStringType(key, String(value))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
getTagStyle,
|
||||
getTagColorKey,
|
||||
setTagColor,
|
||||
DEFAULT_TAG_STYLE,
|
||||
initTagColors,
|
||||
} from './tagStyles'
|
||||
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from './vaultConfigStore'
|
||||
@@ -19,8 +18,11 @@ describe('tagStyles — color overrides', () => {
|
||||
initTagColors({})
|
||||
})
|
||||
|
||||
it('returns default style when no override exists', () => {
|
||||
expect(getTagStyle('SomeTag')).toEqual(DEFAULT_TAG_STYLE)
|
||||
it('returns a hash-based style when no override exists', () => {
|
||||
const style = getTagStyle('SomeTag')
|
||||
// Should have bg and color properties from the accent palette
|
||||
expect(style.bg).toMatch(/^var\(--accent-\w+-light\)$/)
|
||||
expect(style.color).toMatch(/^var\(--accent-\w+\)$/)
|
||||
})
|
||||
|
||||
it('getTagColorKey returns null when no override set', () => {
|
||||
@@ -47,7 +49,9 @@ describe('tagStyles — color overrides', () => {
|
||||
expect(getTagColorKey('React')).toBe('red')
|
||||
setTagColor('React', null)
|
||||
expect(getTagColorKey('React')).toBeNull()
|
||||
expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE)
|
||||
// Falls back to hash-based color (no longer DEFAULT_TAG_STYLE)
|
||||
const style = getTagStyle('React')
|
||||
expect(style.bg).toMatch(/^var\(--accent-\w+-light\)$/)
|
||||
})
|
||||
|
||||
it('applies different overrides for different tags', () => {
|
||||
@@ -57,10 +61,26 @@ describe('tagStyles — color overrides', () => {
|
||||
expect(getTagStyle('TypeScript').color).toBe('var(--accent-purple)')
|
||||
})
|
||||
|
||||
it('returns deterministic hash-based color when no override exists', () => {
|
||||
const style1 = getTagStyle('React')
|
||||
const style2 = getTagStyle('React')
|
||||
// Same tag → same color every time
|
||||
expect(style1).toEqual(style2)
|
||||
// Should NOT be the old default (all-blue) — should vary by tag name
|
||||
const styleA = getTagStyle('React')
|
||||
const styleB = getTagStyle('TypeScript')
|
||||
const styleC = getTagStyle('Tauri')
|
||||
// At least two of three should differ (hash distribution)
|
||||
const colors = [styleA.color, styleB.color, styleC.color]
|
||||
const unique = new Set(colors)
|
||||
expect(unique.size).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('ignores invalid color key in override', () => {
|
||||
setTagColor('React', 'nonexistent-color')
|
||||
// Falls back to default since "nonexistent-color" isn't a valid ACCENT_COLOR key
|
||||
expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE)
|
||||
// Falls back to hash-based color since "nonexistent-color" isn't a valid ACCENT_COLOR key
|
||||
const style = getTagStyle('React')
|
||||
expect(style.bg).toMatch(/^var\(--accent-\w+-light\)$/)
|
||||
})
|
||||
|
||||
it('persists multiple overrides to vault config', () => {
|
||||
|
||||
@@ -11,6 +11,17 @@ export const DEFAULT_TAG_STYLE: TagStyle = {
|
||||
color: 'var(--accent-blue)',
|
||||
}
|
||||
|
||||
/** Deterministic hash → accent color index for tags without a manual override. */
|
||||
function hashTagColor(tag: string): TagStyle {
|
||||
let hash = 0
|
||||
for (let i = 0; i < tag.length; i++) {
|
||||
hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0
|
||||
}
|
||||
const idx = ((hash % ACCENT_COLORS.length) + ACCENT_COLORS.length) % ACCENT_COLORS.length
|
||||
const accent = ACCENT_COLORS[idx]
|
||||
return { bg: accent.cssLight, color: accent.css }
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'laputa:tag-color-overrides'
|
||||
|
||||
const COLOR_KEY_TO_STYLE: Record<string, TagStyle> = Object.fromEntries(
|
||||
@@ -53,5 +64,5 @@ export function getTagStyle(tag: string): TagStyle {
|
||||
const style = COLOR_KEY_TO_STYLE[overrideKey]
|
||||
if (style) return style
|
||||
}
|
||||
return DEFAULT_TAG_STYLE
|
||||
return hashTagColor(tag)
|
||||
}
|
||||
|
||||
@@ -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+\. /)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, findCommand } from './helpers'
|
||||
|
||||
test.describe('Open in New Window', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('"Open in New Window" command appears in palette when note is active', async ({ page }) => {
|
||||
// Open a note first (click first item in note list)
|
||||
const firstNote = page.locator('.cursor-pointer.border-b').first()
|
||||
await expect(firstNote).toBeVisible({ timeout: 3_000 })
|
||||
await firstNote.click()
|
||||
|
||||
// Wait for editor to load
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 3_000 })
|
||||
|
||||
// Open command palette and search
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'Open in New Window')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('"Open in New Window" shows shortcut hint', async ({ page }) => {
|
||||
// Open a note
|
||||
const firstNote = page.locator('.cursor-pointer.border-b').first()
|
||||
await expect(firstNote).toBeVisible({ timeout: 3_000 })
|
||||
await firstNote.click()
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 3_000 })
|
||||
|
||||
// Open command palette and search for the command
|
||||
await openCommandPalette(page)
|
||||
const input = page.locator('input[placeholder="Type a command..."]')
|
||||
await input.fill('Open in New Window')
|
||||
|
||||
// The shortcut hint should be visible
|
||||
const commandRow = page.locator('text=Open in New Window').first()
|
||||
await expect(commandRow).toBeVisible({ timeout: 2_000 })
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
async function openNoteViaQuickOpen(page: import('@playwright/test').Page, query: string) {
|
||||
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(query)
|
||||
await page.waitForTimeout(500)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
test.describe('Type instances in inspector', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('shows Instances section when viewing a Type note', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Project')
|
||||
|
||||
// The inspector starts open by default. Look for the "Properties" header.
|
||||
// If it's collapsed, click the SlidersHorizontal icon to expand.
|
||||
const propertiesHeader = page.getByText('Properties', { exact: true })
|
||||
if (!(await propertiesHeader.isVisible())) {
|
||||
// Inspector might be collapsed — click the toggle button
|
||||
const toggleBtn = page.locator('button').filter({ has: page.locator('svg') }).last()
|
||||
await toggleBtn.click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
// The Instances section should be visible with a count
|
||||
const instancesLabel = page.getByText(/Instances \(\d+\)/)
|
||||
await expect(instancesLabel).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('does not show Instances section for non-Type note', async ({ page }) => {
|
||||
await openNoteViaQuickOpen(page, 'Build Laputa App')
|
||||
|
||||
// No Instances section should appear for a non-Type note
|
||||
const instancesLabel = page.getByText(/Instances \(\d+\)/)
|
||||
await expect(instancesLabel).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('NoteList split refactor – no behavior changes', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('note list container renders and shows notes', async ({ page }) => {
|
||||
const container = page.locator('[data-testid="note-list-container"]')
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
// Notes should render inside the container
|
||||
const noteItems = container.locator('.cursor-pointer')
|
||||
await expect(noteItems.first()).toBeVisible({ timeout: 5000 })
|
||||
const count = await noteItems.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('header with title and action buttons renders', async ({ page }) => {
|
||||
// The header area sits above the note-list-container: h3 title + action buttons
|
||||
const header = page.locator('h3.truncate')
|
||||
await expect(header).toBeVisible()
|
||||
const title = await header.textContent()
|
||||
expect(title && title.length > 0).toBe(true)
|
||||
|
||||
// Search button (magnifying glass) should be present
|
||||
const searchBtn = page.locator('button[title="Search notes"]')
|
||||
await expect(searchBtn).toBeVisible()
|
||||
|
||||
// Create note button should be present (when not in trash view)
|
||||
const createBtn = page.locator('button[title="Create new note"]')
|
||||
await expect(createBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('search toggle shows and hides search input', async ({ page }) => {
|
||||
const searchBtn = page.locator('button[title="Search notes"]')
|
||||
await searchBtn.click()
|
||||
|
||||
const searchInput = page.locator('input[placeholder="Search notes..."]')
|
||||
await expect(searchInput).toBeVisible()
|
||||
|
||||
// Toggle off
|
||||
await searchBtn.click()
|
||||
await expect(searchInput).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking a note opens it in the editor', async ({ page }) => {
|
||||
const container = page.locator('[data-testid="note-list-container"]')
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
const noteItems = container.locator('.cursor-pointer')
|
||||
await expect(noteItems.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Get the title of the first note
|
||||
const noteTitle = await noteItems.first().locator('.font-medium, .font-semibold, .font-bold').first().textContent()
|
||||
|
||||
// Click the first note
|
||||
await noteItems.first().click()
|
||||
|
||||
// After click, the editor area or tab bar should reflect the opened note
|
||||
// Verify the note list container is still functional (no crash from refactor)
|
||||
await expect(container).toBeVisible()
|
||||
expect(noteTitle && noteTitle.length > 0).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('split-usenoteactions: note creation and rename still work', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('Cmd+N creates a new untitled note visible in the sidebar', async ({ page }) => {
|
||||
await page.keyboard.press('Meta+n')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// The new note should appear in the note list sidebar
|
||||
const noteList = page.locator('.app__note-list')
|
||||
const untitledItem = noteList.locator('text=/untitled/i').first()
|
||||
await expect(untitledItem).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('creating multiple notes generates unique names in the sidebar', async ({ page }) => {
|
||||
await page.keyboard.press('Meta+n')
|
||||
await page.waitForTimeout(200)
|
||||
await page.keyboard.press('Meta+n')
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Both notes should be visible in the sidebar with different names
|
||||
const noteList = page.locator('.app__note-list')
|
||||
const untitledItems = noteList.locator('text=/untitled/i')
|
||||
await expect(untitledItems.first()).toBeVisible({ timeout: 3000 })
|
||||
const count = await untitledItems.count()
|
||||
expect(count).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('selecting a note opens it without errors', async ({ page }) => {
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Editor area should be visible (no crash from the refactored hooks)
|
||||
const editor = page.locator('.app__editor')
|
||||
await expect(editor).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('frontmatter property update shows toast', async ({ page }) => {
|
||||
// Select an existing note
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Inspector panel renders without errors from frontmatterOps extraction
|
||||
const inspector = page.getByTestId('inspector')
|
||||
if (await inspector.isVisible()) {
|
||||
await expect(inspector).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, findCommand, executeCommand } from './helpers'
|
||||
|
||||
test.describe('Three source of truth', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('Reload Vault command appears in Cmd+K palette', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'reload')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('Reload Vault command is executable', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'reload vault')
|
||||
// After execution, palette should close and app should still render
|
||||
await expect(page.locator('input[placeholder="Type a command..."]')).not.toBeVisible()
|
||||
// Sidebar should still be visible after reload
|
||||
await expect(page.locator('[data-testid="sidebar"], nav, aside').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Reload Vault findable by keyword "rescan"', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
// Type a keyword synonym — the command should appear as selected result
|
||||
await page.locator('input[placeholder="Type a command..."]').fill('rescan')
|
||||
const match = page.locator('[data-selected="true"]').first()
|
||||
await match.waitFor({ timeout: 2_000 })
|
||||
const text = await match.textContent()
|
||||
expect(text?.toLowerCase()).toContain('reload')
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Title H1 with inline emoji', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open a note so TitleField is visible
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('title-section__row is a horizontal flex container', async ({ page }) => {
|
||||
const row = page.locator('.title-section__row')
|
||||
await expect(row).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const display = await row.evaluate(el => getComputedStyle(el).display)
|
||||
const flexDir = await row.evaluate(el => getComputedStyle(el).flexDirection)
|
||||
expect(display).toBe('flex')
|
||||
expect(flexDir).toBe('row')
|
||||
})
|
||||
|
||||
test('title field renders with large H1 font size', async ({ page }) => {
|
||||
const input = page.locator('.title-field__input')
|
||||
await expect(input).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const fontSize = await input.evaluate(el => {
|
||||
const computed = getComputedStyle(el)
|
||||
return parseFloat(computed.fontSize)
|
||||
})
|
||||
// Title must be at least 24px (H1 style, significantly larger than body ~16px)
|
||||
expect(fontSize).toBeGreaterThanOrEqual(24)
|
||||
})
|
||||
|
||||
test('title field renders with bold font weight', async ({ page }) => {
|
||||
const input = page.locator('.title-field__input')
|
||||
await expect(input).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const fontWeight = await input.evaluate(el => {
|
||||
const computed = getComputedStyle(el)
|
||||
return parseInt(computed.fontWeight, 10)
|
||||
})
|
||||
// Font weight 700+ (bold or heavier)
|
||||
expect(fontWeight).toBeGreaterThanOrEqual(700)
|
||||
})
|
||||
|
||||
test('emoji icon and title are on the same horizontal line when icon present', async ({ page }) => {
|
||||
// Add an icon via the "Add icon" button
|
||||
const iconArea = page.locator('[data-testid="note-icon-area"]')
|
||||
await iconArea.hover()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
const addBtn = page.locator('[data-testid="note-icon-add"]')
|
||||
if (await addBtn.isVisible()) {
|
||||
await addBtn.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Pick the first emoji in the picker
|
||||
const emojiBtn = page.locator('.emoji-picker-grid button').first()
|
||||
if (await emojiBtn.isVisible()) {
|
||||
await emojiBtn.click()
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
|
||||
const iconDisplay = page.locator('[data-testid="note-icon-display"]')
|
||||
const titleInput = page.locator('[data-testid="title-field-input"]')
|
||||
|
||||
if (await iconDisplay.isVisible()) {
|
||||
const iconBox = await iconDisplay.boundingBox()
|
||||
const titleBox = await titleInput.boundingBox()
|
||||
|
||||
expect(iconBox).not.toBeNull()
|
||||
expect(titleBox).not.toBeNull()
|
||||
|
||||
// Emoji and title must be on the same row: their vertical centers overlap
|
||||
const iconCenter = iconBox!.y + iconBox!.height / 2
|
||||
const titleTop = titleBox!.y
|
||||
const titleBottom = titleBox!.y + titleBox!.height
|
||||
|
||||
expect(iconCenter).toBeGreaterThanOrEqual(titleTop - 8)
|
||||
expect(iconCenter).toBeLessThanOrEqual(titleBottom + 8)
|
||||
|
||||
// Emoji must be to the left of the title
|
||||
expect(iconBox!.x).toBeLessThan(titleBox!.x)
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking title still enters edit mode', async ({ page }) => {
|
||||
const titleInput = page.locator('[data-testid="title-field-input"]')
|
||||
await expect(titleInput).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await titleInput.click()
|
||||
await expect(titleInput).toBeFocused()
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('TitleField alignment and separator', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open a note so TitleField is visible
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('title-section and editor body share the same scroll container', async ({ page }) => {
|
||||
// The .editor-scroll-area should contain both .title-section and .editor__blocknote-container
|
||||
const scrollArea = page.locator('.editor-scroll-area')
|
||||
await expect(scrollArea).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const titleSection = scrollArea.locator('.title-section')
|
||||
await expect(titleSection).toBeVisible()
|
||||
|
||||
const editorContainer = scrollArea.locator('.editor__blocknote-container')
|
||||
await expect(editorContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('separator line is visible between title and editor', async ({ page }) => {
|
||||
const separator = page.locator('.title-section__separator')
|
||||
await expect(separator).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Separator must have a visible border-bottom
|
||||
const borderBottom = await separator.evaluate(
|
||||
el => getComputedStyle(el).borderBottomStyle
|
||||
)
|
||||
expect(borderBottom).toBe('solid')
|
||||
})
|
||||
|
||||
test('title-section and editor content are horizontally aligned', async ({ page }) => {
|
||||
// Resize viewport to a wide width to test alignment
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const titleSection = page.locator('.title-section')
|
||||
const editorBlock = page.locator('.editor__blocknote-container .bn-editor')
|
||||
|
||||
await expect(titleSection).toBeVisible({ timeout: 3000 })
|
||||
await expect(editorBlock).toBeVisible({ timeout: 3000 })
|
||||
|
||||
const titleBox = await titleSection.boundingBox()
|
||||
const editorBox = await editorBlock.boundingBox()
|
||||
|
||||
expect(titleBox).not.toBeNull()
|
||||
expect(editorBox).not.toBeNull()
|
||||
|
||||
// Both should have the same left edge (within 2px tolerance)
|
||||
expect(Math.abs(titleBox!.x - editorBox!.x)).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('title field uses H1 CSS variables for font styling', async ({ page }) => {
|
||||
// Verify the title-field__input element references the H1 heading CSS vars
|
||||
const usesH1Vars = await page.evaluate(() => {
|
||||
const sheets = Array.from(document.styleSheets)
|
||||
for (const sheet of sheets) {
|
||||
try {
|
||||
for (const rule of Array.from(sheet.cssRules)) {
|
||||
if (rule instanceof CSSStyleRule && rule.selectorText === '.title-field__input') {
|
||||
const fontSize = rule.style.getPropertyValue('font-size')
|
||||
const fontWeight = rule.style.getPropertyValue('font-weight')
|
||||
return {
|
||||
fontSize: fontSize.includes('--headings-h1-font-size'),
|
||||
fontWeight: fontWeight.includes('--headings-h1-font-weight'),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin sheet */ }
|
||||
}
|
||||
return { fontSize: false, fontWeight: false }
|
||||
})
|
||||
|
||||
expect(usesH1Vars.fontSize).toBe(true)
|
||||
expect(usesH1Vars.fontWeight).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Type icon, color, and sidebar label', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Block vault API so the app falls back to mock data (which contains our test fixtures)
|
||||
await page.route('**/api/vault/**', (route) => route.abort())
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('Config section shows correct sidebar label from type entry', async ({ page }) => {
|
||||
const sidebar = page.locator('aside')
|
||||
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
|
||||
|
||||
// The Config type entry has sidebarLabel: "Config" — the section button should use that label
|
||||
const configBtn = sidebar.locator('button[aria-label="Collapse Config"], button[aria-label="Expand Config"]')
|
||||
await expect(configBtn).toBeVisible()
|
||||
})
|
||||
|
||||
test('Config section icon is not the default FileText', async ({ page }) => {
|
||||
const sidebar = page.locator('aside')
|
||||
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
|
||||
|
||||
const configSection = sidebar.locator('div.group\\/section').filter({
|
||||
has: page.locator('button[aria-label="Collapse Config"], button[aria-label="Expand Config"]'),
|
||||
})
|
||||
await expect(configSection).toBeVisible()
|
||||
|
||||
// The icon SVG should be present (GearSix, resolved from type entry icon: 'gear-six')
|
||||
const icon = configSection.locator('svg').first()
|
||||
await expect(icon).toBeVisible()
|
||||
})
|
||||
|
||||
test('Config section icon has gray color applied via style', async ({ page }) => {
|
||||
const sidebar = page.locator('aside')
|
||||
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
|
||||
|
||||
const configSection = sidebar.locator('div.group\\/section').filter({
|
||||
has: page.locator('button[aria-label="Collapse Config"], button[aria-label="Expand Config"]'),
|
||||
})
|
||||
await expect(configSection).toBeVisible()
|
||||
|
||||
// The icon should have gray color from the type entry's color: 'gray'
|
||||
const icon = configSection.locator('svg').first()
|
||||
const color = await icon.evaluate((el) => el.style.color)
|
||||
expect(color).toContain('var(--accent-gray)')
|
||||
})
|
||||
|
||||
test('custom type with icon/color reflects in sidebar (Recipe)', async ({ page }) => {
|
||||
const sidebar = page.locator('aside')
|
||||
await sidebar.locator('button[aria-label*="Collapse"], button[aria-label*="Expand"]').first().waitFor({ timeout: 5000 })
|
||||
|
||||
// Recipe type has icon: cooking-pot, color: orange — check section has correct color
|
||||
const recipeSection = sidebar.locator('div.group\\/section').filter({
|
||||
has: page.locator('button[aria-label="Collapse Recipes"], button[aria-label="Expand Recipes"]'),
|
||||
})
|
||||
await expect(recipeSection).toBeVisible()
|
||||
|
||||
const icon = recipeSection.locator('svg').first()
|
||||
const color = await icon.evaluate((el) => el.style.color)
|
||||
expect(color).toContain('var(--accent-orange)')
|
||||
})
|
||||
})
|
||||
33886
ui-design.pen
33886
ui-design.pen
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user