Compare commits
27 Commits
v0.2026033
...
v0.2026033
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd130171df | ||
|
|
2045e13404 | ||
|
|
d83121bc83 | ||
|
|
acfceb3335 | ||
|
|
2dd6a94ef8 | ||
|
|
296d474732 | ||
|
|
98a98ab024 | ||
|
|
2b85640521 | ||
|
|
c3b397f900 | ||
|
|
6e0b578590 | ||
|
|
860efc1f42 | ||
|
|
d05bc271a8 | ||
|
|
7f0134a99c | ||
|
|
8fbf035d46 | ||
|
|
859795879c | ||
|
|
0ee4862508 | ||
|
|
e1def7f8bb | ||
|
|
e697b4b5e5 | ||
|
|
6b0bb5173c | ||
|
|
81f986a065 | ||
|
|
564ca50206 | ||
|
|
6d405a763d | ||
|
|
7c9bc3d640 | ||
|
|
d316539a91 | ||
|
|
0f22475c20 | ||
|
|
797c7b66b6 | ||
|
|
af7d79fe44 |
42
.claude/commands/laputa-done.md
Normal file
42
.claude/commands/laputa-done.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# /laputa-done <task_id>
|
||||
|
||||
Mark a Laputa task as done: add completion comment, move to In Review, 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.83
|
||||
AVERAGE_THRESHOLD=9.38
|
||||
@@ -110,12 +110,19 @@ else
|
||||
echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)"
|
||||
fi
|
||||
|
||||
# ── 5. CodeScene code health gate ────────────────────────────────────────
|
||||
# Blocks push if Hotspot < 9.5 OR Average < 9.33.
|
||||
# Remote scores reflect state after last push — this catches regressions
|
||||
# introduced in a previous push that weren't caught yet.
|
||||
# ── 5. CodeScene code health gate (ratchet) ──────────────────────────────
|
||||
# Thresholds live in .codescene-thresholds and only ever go UP (ratchet).
|
||||
# After every successful push the file is updated if scores improved.
|
||||
THRESHOLDS_FILE="$(git rev-parse --show-toplevel)/.codescene-thresholds"
|
||||
HOTSPOT_MIN=9.5
|
||||
AVERAGE_MIN=9.31
|
||||
if [ -f "$THRESHOLDS_FILE" ]; then
|
||||
HOTSPOT_MIN=$(grep HOTSPOT_THRESHOLD "$THRESHOLDS_FILE" | cut -d= -f2)
|
||||
AVERAGE_MIN=$(grep AVERAGE_THRESHOLD "$THRESHOLDS_FILE" | cut -d= -f2)
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🏥 [5/5] CodeScene code health (Hotspot ≥9.5 + Average ≥9.33)..."
|
||||
echo "🏥 [5/5] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
|
||||
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
|
||||
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
|
||||
else
|
||||
@@ -128,25 +135,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
|
||||
|
||||
201
CLAUDE.md
201
CLAUDE.md
@@ -1,38 +1,52 @@
|
||||
# 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
|
||||
---
|
||||
|
||||
## 1. Task Workflow
|
||||
|
||||
This is how you pick up and complete a task. Follow this order every time.
|
||||
|
||||
### 1a. Pick up a task
|
||||
|
||||
Run `/laputa-next-task` — it fetches the next task from Todoist (To Rework first, then Open), moves it to In Progress, and returns the full description.
|
||||
|
||||
When starting a task:
|
||||
- Read the task description and comments fully
|
||||
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
|
||||
- Check `docs/adr/` for relevant architecture decisions before making structural choices
|
||||
- **Add a comment** when you move the task to In Progress:
|
||||
```
|
||||
🚀 Starting work on this task. [Brief description of approach]
|
||||
```
|
||||
|
||||
### 1b. Implement
|
||||
|
||||
- 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 the visual language, design in light mode
|
||||
|
||||
### 1c. When done — three mandatory steps
|
||||
|
||||
**Step 1: Move task to In### 1c. When done
|
||||
|
||||
After Phase 1 (Playwright) and Phase 2 (native QA) both pass, run:
|
||||
|
||||
```
|
||||
/laputa-done <task_id>
|
||||
```
|
||||
|
||||
**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)
|
||||
This moves the task to In Review, notifies Brian, and self-dispatches the next task automatically.
|
||||
|
||||
**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)
|
||||
nal (steps 1–3 above).
|
||||
|
||||
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.
|
||||
**Phase 1 — Playwright (you, only when needed):**
|
||||
|
||||
**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.
|
||||
Write a smoke test in `tests/smoke/<slug>.spec.ts` only if the feature touches a **core user flow**: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Do NOT write Playwright tests for cosmetic/UI-only changes (padding, chip size, label text, color, border) — use Vitest instead.
|
||||
|
||||
This is not optional — it's how we incrementally raise the codebase quality with every task.
|
||||
|
||||
## ⛔ BEFORE FIRING laputa-task-done — Two-phase QA
|
||||
|
||||
### Phase 1: Playwright (you do this)
|
||||
|
||||
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:
|
||||
The full Playwright suite must stay under **10 minutes**. If your new test would push it over, remove an existing non-core test first.
|
||||
|
||||
```bash
|
||||
pnpm dev --port 5201 &
|
||||
@@ -40,95 +54,116 @@ 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 app QA (also you):**
|
||||
|
||||
### 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**:
|
||||
Run the app in dev mode and test natively — no need to build a release DMG:
|
||||
|
||||
```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 # wait for app to start
|
||||
```
|
||||
|
||||
## Project
|
||||
Then use the QA scripts:
|
||||
```bash
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
# Analyze screenshot with image tool — verify the feature looks correct
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
|
||||
```
|
||||
|
||||
Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with YAML frontmatter.
|
||||
Use `osascript` for keyboard interactions. Write the result as a Todoist comment on the task:
|
||||
- ✅ if native QA passes (describe what you tested and saw)
|
||||
- ❌ if it fails (describe what's wrong) — fix and repeat from Phase 1
|
||||
|
||||
- **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
|
||||
**⚠️ WKWebView limitation:** `osascript keystroke` is blocked inside the editor for text input. For features requiring text input: verify app launches + stability, then rely on Playwright for correctness.
|
||||
|
||||
## How to Work
|
||||
---
|
||||
|
||||
- **Push directly to main** — no PRs ever. The pre-push hook runs all checks.
|
||||
- **⛔ NEVER open a PR** — branches diverge and cause rebase churn.
|
||||
## 2. Development Process
|
||||
|
||||
### Commits & pushes
|
||||
|
||||
- Push directly to `main` — no PRs, no branches
|
||||
- The pre-push hook runs the full check suite (build + tests + Playwright + CodeScene)
|
||||
- **⛔ NEVER use --no-verify**
|
||||
- Commit every 20–30 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
|
||||
- Commit message format: `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 a failing regression test first, then fix.
|
||||
Exception: pure CSS/layout changes with no logic.
|
||||
|
||||
**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** ≥ threshold in `.codescene-thresholds`
|
||||
- **Average Code Health** ≥ threshold in `.codescene-thresholds`
|
||||
|
||||
## Architecture Decision Records (ADRs)
|
||||
Both gates block commit/push. Thresholds are a **ratchet** — they only go up, auto-updated after each successful push. Never add `// eslint-disable`, `#[allow(...)]`, or `as any` to pass them.
|
||||
|
||||
ADRs live in `docs/adr/`. Before making an architectural choice, check existing ADRs there first.
|
||||
**Before every commit:** run checks via MCP CodeScene:
|
||||
- `mcp__codescene__code_health_review` — check file before touching it
|
||||
- `mcp__codescene__code_health_score` — verify score is higher after your changes
|
||||
|
||||
**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.
|
||||
**Boy Scout Rule:** every file you touch must leave with a higher score than it had. If Average drops below 9.0, fix regressions before pushing.
|
||||
|
||||
**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.
|
||||
### 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
|
||||
```
|
||||
|
||||
**When your work supersedes an existing ADR**: do not edit the existing file — use `/create-adr` which covers the superseding flow.
|
||||
### Architecture Decision Records (ADRs)
|
||||
|
||||
**Do not create ADRs for**: bug fixes, UI styling, refactors, or test additions.
|
||||
ADRs live in `docs/adr/`. Check them before making structural choices.
|
||||
|
||||
## Design File (UI tasks)
|
||||
**When to create one:** storage strategy, new dependency, platform support, core abstraction change, cross-cutting concern. Use `/create-adr` for the template.
|
||||
|
||||
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`.
|
||||
**Timing:** create the ADR **in the same commit as the code** — never before, never after.
|
||||
|
||||
## ⛔ Never modify the user vault for testing
|
||||
**Superseding:** never edit an existing ADR — create a new one that supersedes it.
|
||||
|
||||
`~/Laputa/` is Luca's real vault. **Never create, edit, or delete notes there for testing purposes.**
|
||||
**Don't create ADRs for:** bug fixes, UI styling, refactors, test additions.
|
||||
|
||||
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
|
||||
### Keep docs/ in sync
|
||||
|
||||
Any commit that touches `~/Laputa/` content is a bug. If you accidentally created test notes there, delete them before committing.
|
||||
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.
|
||||
|
||||
## Vault Retrocompatibility
|
||||
---
|
||||
|
||||
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.
|
||||
## 3. Product Rules
|
||||
|
||||
## Keyboard-First + Menu Bar (mandatory)
|
||||
### User vault (`~/Laputa/`)
|
||||
|
||||
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.
|
||||
You may use `~/Laputa/` for testing when the demo vault isn't sufficient (e.g. verifying against real git history). But:
|
||||
- **Never commit changes to `~/Laputa/`** — discard them before finishing
|
||||
- After any testing that touched the vault, run: `cd ~/Laputa && git checkout -- . && git clean -fd`
|
||||
- Default to `demo-vault-v2/` whenever possible
|
||||
|
||||
## macOS / Tauri Gotchas
|
||||
### UI design
|
||||
|
||||
- `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.
|
||||
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`
|
||||
|
||||
## QA Scripts
|
||||
---
|
||||
|
||||
## 4. Reference
|
||||
|
||||
### 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 testing
|
||||
|
||||
### QA scripts
|
||||
|
||||
```bash
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
@@ -136,6 +171,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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () =>
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-border"
|
||||
style={{ height: 45, padding: '0 12px', gap: 8 }}
|
||||
style={{ height: 52, padding: '0 12px', gap: 8 }}
|
||||
>
|
||||
<Robot size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
||||
|
||||
@@ -41,7 +41,6 @@ const trashedEntry: VaultEntry = {
|
||||
|
||||
const defaultProps = {
|
||||
wordCount: 100,
|
||||
noteStatus: 'clean' as const,
|
||||
showDiffToggle: false,
|
||||
diffMode: false,
|
||||
diffLoading: false,
|
||||
@@ -84,18 +83,6 @@ describe('BreadcrumbBar — trash/restore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — pending save indicator', () => {
|
||||
it('shows "Saving…" text when noteStatus is pendingSave', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteStatus={'pendingSave'} />)
|
||||
expect(screen.getByText('Saving…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show "Saving…" text for clean status', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteStatus={'clean'} />)
|
||||
expect(screen.queryByText('Saving…')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
it('shows archive button for non-archived note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onArchive={vi.fn()} onUnarchive={vi.fn()} />)
|
||||
|
||||
@@ -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,50 +162,18 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
}
|
||||
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry, wordCount, noteStatus, ...actionProps
|
||||
entry, ...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: 8px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* No emoji: title aligns flush left (no indent for icon area) */
|
||||
.title-section__row--no-icon {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* When emoji is present, restore top padding to the row itself */
|
||||
.title-section__row:has(.note-icon-button--active) {
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.title-section__separator {
|
||||
border-bottom: 1px solid var(--border-primary, rgba(0, 0, 0, 0.08));
|
||||
margin-top: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* --- Note Icon Area --- */
|
||||
@@ -227,7 +246,6 @@
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.title-section:hover .note-icon-button--add,
|
||||
|
||||
@@ -124,10 +124,11 @@ describe('Editor', () => {
|
||||
activeTabPath={mockEntry.path}
|
||||
/>
|
||||
)
|
||||
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
|
||||
// Tab bar renders when a tab is active
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders breadcrumb bar with note info', () => {
|
||||
it('renders breadcrumb bar with action buttons', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
@@ -135,10 +136,8 @@ describe('Editor', () => {
|
||||
activeTabPath={mockEntry.path}
|
||||
/>
|
||||
)
|
||||
// Breadcrumb shows type and title
|
||||
expect(screen.getByText('Project')).toBeInTheDocument()
|
||||
// Word count shown
|
||||
expect(screen.getByText(/words/)).toBeInTheDocument()
|
||||
// Breadcrumb bar shows action buttons (e.g. search, archive)
|
||||
expect(screen.getByTitle('Search in file')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows BlockNote editor when a tab is active', () => {
|
||||
@@ -152,7 +151,7 @@ describe('Editor', () => {
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows modified indicator when file is modified', () => {
|
||||
it('renders editor for modified file without breadcrumb status', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
@@ -161,11 +160,11 @@ describe('Editor', () => {
|
||||
getNoteStatus={() => 'modified'}
|
||||
/>
|
||||
)
|
||||
// Modified indicator shows "M" in the breadcrumb
|
||||
expect(screen.getByText('M')).toBeInTheDocument()
|
||||
// Editor still renders; breadcrumb bar no longer shows status indicators
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows new indicator when file is new', () => {
|
||||
it('renders editor for new file without breadcrumb status', () => {
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
@@ -174,8 +173,8 @@ describe('Editor', () => {
|
||||
getNoteStatus={() => 'new'}
|
||||
/>
|
||||
)
|
||||
// New indicator shows "N" in the breadcrumb
|
||||
expect(screen.getByText('N')).toBeInTheDocument()
|
||||
// Editor still renders; breadcrumb bar no longer shows status indicators
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders diff toggle button when file is modified', () => {
|
||||
|
||||
@@ -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,17 +202,19 @@ 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">
|
||||
{!emojiIcon && (
|
||||
<NoteIcon
|
||||
icon={null}
|
||||
editable={!isTrashed}
|
||||
onSetIcon={handleSetIcon}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
/>
|
||||
<div className="title-section__add-icon">
|
||||
<NoteIcon
|
||||
icon={null}
|
||||
editable={!isTrashed}
|
||||
onSetIcon={handleSetIcon}
|
||||
onRemoveIcon={handleRemoveIcon}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="title-section__row">
|
||||
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
|
||||
{emojiIcon && (
|
||||
<NoteIcon
|
||||
icon={emojiIcon}
|
||||
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -90,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>
|
||||
</>
|
||||
|
||||
@@ -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}` })),
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ResizeHandle({ onResize }: ResizeHandleProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="-ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
|
||||
className="relative z-10 -ml-1 w-1 shrink-0 self-stretch cursor-col-resize bg-transparent transition-colors hover:bg-[var(--border)]"
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('SearchPanel', () => {
|
||||
expect(screen.getByText('Result One')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
|
||||
await waitFor(() => {
|
||||
const resultTwo = screen.getByText('Result Two').closest('[class*="cursor-pointer"]')!
|
||||
|
||||
@@ -389,63 +389,11 @@ describe('Sidebar', () => {
|
||||
expect(screen.queryByTitle('New Project')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders commit button even when no modified files', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} onCommitPush={() => {}} />)
|
||||
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows badge on commit button when modified files exist', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} onCommitPush={() => {}} />)
|
||||
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
|
||||
const badges = screen.getAllByText('3')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows Changes nav item when modifiedCount > 0', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={5} />)
|
||||
expect(screen.getByText('Changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Changes nav item when modifiedCount is 0', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={0} />)
|
||||
it('does not render Changes or Pulse in sidebar', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Changes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with changes filter when clicking Changes', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={onSelect} modifiedCount={3} />)
|
||||
fireEvent.click(screen.getByText('Changes'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'changes' })
|
||||
})
|
||||
|
||||
describe('Changes and Pulse in secondary bottom area', () => {
|
||||
it('renders Changes outside the main top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
|
||||
const changesEl = screen.getByText('Changes')
|
||||
// Changes should be inside the secondary bottom area, not the top nav
|
||||
const secondaryArea = changesEl.closest('[data-testid="sidebar-secondary"]')
|
||||
expect(secondaryArea).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders Pulse outside the main top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} isGitVault />)
|
||||
const pulseEl = screen.getByText('Pulse')
|
||||
const secondaryArea = pulseEl.closest('[data-testid="sidebar-secondary"]')
|
||||
expect(secondaryArea).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not render Changes or Pulse inside the top nav section', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={3} isGitVault />)
|
||||
const topNav = screen.getByTestId('sidebar-top-nav')
|
||||
expect(topNav.textContent).not.toContain('Changes')
|
||||
expect(topNav.textContent).not.toContain('Pulse')
|
||||
})
|
||||
|
||||
it('shows Changes badge count in secondary area', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} modifiedCount={7} isGitVault />)
|
||||
const secondaryArea = screen.getByTestId('sidebar-secondary')
|
||||
expect(secondaryArea.textContent).toContain('7')
|
||||
})
|
||||
expect(screen.queryByText('Pulse')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Commit & Push')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('dynamic custom type sections', () => {
|
||||
|
||||
@@ -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)
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
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