Compare commits

...

5 Commits

Author SHA1 Message Date
Test
8df4730db6 fix: narrow deleted preview active tab path 2026-04-07 20:10:13 +02:00
Test
e5f3bae8f3 feat: show deleted notes as restorable changes 2026-04-07 20:09:04 +02:00
Test
1f39501ce5 fix: keep view filter values as plain text 2026-04-07 19:40:54 +02:00
Test
10024dbf53 test: slim the pre-push smoke lane 2026-04-07 19:22:11 +02:00
Test
e52d07affc chore: enforce main-only workflow and adopt AGENTS.md 2026-04-07 18:42:41 +02:00
44 changed files with 1105 additions and 1266 deletions

275
.github/HOOKS.md vendored
View File

@@ -1,257 +1,64 @@
# Git Hooks
## Pre-Commit Hook: CodeScene Check
This repo uses Husky hooks from `.husky/`. Those files are the source of truth.
Il repository ha un pre-commit hook che verifica la qualità del codice prima di ogni commit.
## Installation
## Post-Commit Hook: Auto-Implement Design Changes
`pnpm install` runs the `prepare` script and installs the hooks into `.git/hooks`.
Quando committi modifiche a `ui-design.pen`, il post-commit hook:
1. Analizza automaticamente le modifiche (colori, typography, spacing, layout)
2. Spawna Claude Code in background per implementare le modifiche
3. Ti notifica quando l'implementazione è completa
---
## Pre-Commit Hook Details
### Cosa Fa
1. **Analizza file staged** — controlla solo TypeScript/Rust modificati
2. **Confronta con base branch**`origin/main` per branch, `HEAD~1` per main
3. **Avvisa per file grandi** — >500 linee modificate
4. **Suggerisce review** — con Claude Code + CodeScene MCP per analisi dettagliata
### Bypass Hook
Se sai cosa stai facendo:
If you need to reinstall them manually:
```bash
# Skip hook per questo commit
git commit --no-verify -m "your message"
# O includi nel commit message
git commit -m "your message [skip codescene]"
pnpm exec husky
```
### Installazione (già fatto per questa repo)
The hooks expect `node` and `pnpm` to be available. If they are installed via `nvm`, the hooks will try to load `~/.nvm/nvm.sh` automatically.
L'hook è già installato in `.git/hooks/pre-commit`.
## Policy
Se cloni la repo altrove, copia l'hook:
```bash
cp .github/hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
```
- Commit on `main` only.
- Push from `main` to `origin/main` only.
- Never use `--no-verify`.
- `.codescene-thresholds` is a ratchet. It can only move up.
### Esempio Output
## Pre-commit
#### ✅ Commit Normale
```
🔍 Running CodeScene Code Health check...
Comparing against: origin/main
Analyzing code changes...
✅ CodeScene check passed
+42 -18 lines
`.husky/pre-commit` blocks commits unless all of the following are true:
💡 For detailed code health analysis, run:
claude 'Check code health of this commit with CodeScene MCP'
```
- `HEAD` is attached to `main`
- staged TypeScript files pass `pnpm lint --quiet`
- TypeScript passes `npx tsc --noEmit`
- frontend tests pass via `pnpm test --run --silent`
- current CodeScene Hotspot and Average health are both at or above `.codescene-thresholds`
#### ⚠️ File Grandi
```
🔍 Running CodeScene Code Health check...
Comparing against: origin/main
Analyzing code changes...
⚠️ Large file changes detected (>500 lines):
- src/components/Editor.tsx
- src-tauri/src/vault.rs
If `CODESCENE_PAT` or `CODESCENE_PROJECT_ID` is missing, the CodeScene portion is skipped, but the rest of the hook still runs.
Consider:
- Breaking into smaller commits
- Reviewing with Claude Code + CodeScene MCP
- Running: claude 'Review code health of staged changes'
## Pre-push
Continue anyway? (y/N)
```
`.husky/pre-push` blocks pushes unless all of the following are true:
### CodeScene MCP Integration
- the current branch is `main`
- every pushed branch ref is `refs/heads/main -> refs/heads/main`
- TypeScript and the Vite build pass
- frontend coverage passes
- Rust lint and Rust coverage pass when `src-tauri/` changed
- the curated Playwright core smoke lane passes via `pnpm playwright:smoke`
- current CodeScene Hotspot and Average health are both at or above `.codescene-thresholds`
Per analisi dettagliata del code health, usa Claude Code:
If the remote CodeScene scores are better than the current thresholds, the hook updates `.codescene-thresholds`, stages it, and stops the push. Commit that file normally, then push again. The hook does not auto-commit or bypass itself.
## Legacy Files
The legacy `pre-commit` and `post-commit` files under `.github/hooks/` are archival only. Do not copy them into `.git/hooks`; use Husky and `.husky/` instead. `install-hooks.sh` remains as a reinstall helper that runs Husky.
## Troubleshooting
If a hook cannot find `node` or `pnpm`:
```bash
# Analizza staged changes
claude 'Check code health of staged changes with CodeScene MCP'
# Analizza file specifico
claude 'What is the code health score of src/components/Editor.tsx?'
# Pre-commit safeguard
claude 'Run pre_commit_code_health_safeguard on staged changes'
export NVM_DIR="$HOME/.nvm"
. "$NVM_DIR/nvm.sh"
nvm use node
```
### Troubleshooting
**Hook non si attiva:**
- Verifica che `.git/hooks/pre-commit` esista ed sia eseguibile
- `ls -la .git/hooks/pre-commit` — dovrebbe mostrare `-rwxr-xr-x`
**Vuoi disabilitare temporaneamente:**
```bash
mv .git/hooks/pre-commit .git/hooks/pre-commit.disabled
```
**Vuoi riabilitare:**
```bash
mv .git/hooks/pre-commit.disabled .git/hooks/pre-commit
```
### Future Improvements
Possibili miglioramenti:
- [ ] Integrazione diretta API CodeScene per score numerico
- [ ] Fail automatico se code health < soglia
- [ ] Cache dei risultati per evitare re-analisi
- [ ] Hook pre-push più pesante per analisi completa
---
## Post-Commit Hook: Auto-Implement Design Changes
### Cosa Fa
Quando committi modifiche a `ui-design.pen`, il hook:
1. **Analizza il diff** — usa `scripts/design-diff-analyzer.js`
2. **Identifica modifiche significative:**
- 🎨 Colori (fill, backgroundColor)
- 📝 Typography (fontSize, fontFamily)
- 📏 Spacing (padding, margin, gap)
- 🔲 Layout (nuovi componenti, riorganizzazioni)
3. **Spawna Claude Code** — in background via `openclaw sessions spawn`
4. **Auto-notifica** — quando l'implementazione è completa
### Cosa Implementa
| Tipo Modifica | Azione |
|--------------|--------|
| Colori | Aggiorna `src/theme.json` o CSS variables |
| Typography | Aggiorna `src/theme.json` typography |
| Spacing | Aggiorna `src/theme.json` spacing |
| Layout | Modifica/crea componenti React |
| Testi mockup | Nessuna azione (solo design) |
### Esempio Output
```
🎨 Design file changed - analyzing...
📋 Implementation tasks:
1. [HIGH] Update color palette
- fill: $--muted-foreground → #666666
- backgroundColor: #FFFFFF → #F5F5F5
Update src/theme.json or CSS variables to match the design.
2. [MEDIUM] Update typography
- fontSize: 14px → 16px
Update src/theme.json typography settings.
🚀 Spawning Claude Code to implement changes...
✅ Claude Code spawned - you'll be notified when implementation is complete
```
### Workflow Completo
```
You Post-Commit Hook Claude Code Brian (AI)
│ │ │ │
├─ Modify ui-design.pen │ │ │
├─ git add ui-design.pen │ │ │
├─ git commit │ │ │
│ │ │ │
│ ├─ Analyze diff │ │
│ ├─ Generate tasks │ │
│ ├─ Spawn Claude Code ────────> │
│ │ │ │
│ │ ├─ Implement changes │
│ │ ├─ Test visually │
│ │ ├─ Run tests │
│ │ ├─ Commit │
│ │ ├─ openclaw system event ────>
│ │ │ │
│ │ │ ├─ Notify Telegram
│ <─────────────────────────────────────────────────────────────────────────────┘
│ "✅ Design changes implemented and tested"
```
### Design Diff Analyzer
Lo script `scripts/design-diff-analyzer.js` rileva:
- **Color changes** — `"fill": "#OLD" → "#NEW"`
- **Font changes** — `"fontSize": 14 → 16`
- **Spacing** — `"padding": 8 → 12`
- **Content** — testi mockup (no implementation)
Uso:
```bash
# Analizza ultimo commit
node scripts/design-diff-analyzer.js
# Output JSON per automation
node scripts/design-diff-analyzer.js --json
```
### Disabilitare Temporaneamente
Se vuoi committare il design senza auto-implementazione:
```bash
# Disabilita hook
mv .git/hooks/post-commit .git/hooks/post-commit.disabled
# Commit
git commit -m "design: update mockup"
# Riabilita hook
mv .git/hooks/post-commit.disabled .git/hooks/post-commit
```
### Monitorare Claude Code
Mentre Claude Code lavora in background:
```bash
# Lista sub-agent attivi
openclaw sessions list --kinds isolated
# Vedi log di un sub-agent
openclaw sessions history --session-key <key>
# Ferma sub-agent (se necessario)
openclaw subagents kill --target design-auto-implement
```
### Troubleshooting
**Hook non parte:**
- Verifica che `ui-design.pen` sia effettivamente cambiato: `git diff HEAD~1 ui-design.pen`
- Verifica che lo script analyzer esista: `ls -la scripts/design-diff-analyzer.js`
**Nessuna notifica:**
- Claude Code potrebbe essere ancora in esecuzione — controlla `openclaw sessions list`
- Verifica che il prompt includa `openclaw system event` al termine
**Modifiche non implementate:**
- Controlla i log di Claude Code: `openclaw sessions history --session-key <key>`
- Il sub-agent viene auto-eliminato dopo completion (cleanup=delete)
### Limitazioni
- **Modifiche complesse** — layout completamente nuovi potrebbero richiedere intervento manuale
- **Timeout** — 10 minuti max (configurabile in hook)
- **Solo modifiche recenti** — analizza solo HEAD vs HEAD~1
Then retry the commit or push.

View File

@@ -1,25 +1,26 @@
#!/bin/bash
# Install git hooks for laputa-app
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOKS_DIR="$(git rev-parse --git-dir)/hooks"
if ! command -v pnpm >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1; then
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$NVM_DIR/nvm.sh" --no-use
nvm use --silent node >/dev/null 2>&1 || true
fi
fi
echo "Installing git hooks..."
# Copy pre-commit hook
cp "$SCRIPT_DIR/pre-commit" "$HOOKS_DIR/pre-commit"
chmod +x "$HOOKS_DIR/pre-commit"
echo "✅ Installed pre-commit hook"
# Copy post-commit hook
cp "$SCRIPT_DIR/post-commit" "$HOOKS_DIR/post-commit"
chmod +x "$HOOKS_DIR/post-commit"
echo "✅ Installed post-commit hook"
if ! command -v pnpm >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1; then
echo "❌ node and pnpm must be available to install Husky hooks"
exit 1
fi
echo "Installing Husky hooks from .husky/ ..."
pnpm exec husky
echo "✅ Husky hooks installed"
echo ""
echo "Hooks installed:"
echo " - pre-commit: CodeScene code health check"
echo " - post-commit: Auto-implement design changes via Claude Code"
echo "Source of truth:"
echo " - .husky/pre-commit"
echo " - .husky/pre-push"
echo ""
echo "To bypass pre-commit, use: git commit --no-verify"
echo "Or include [skip codescene] in your commit message"
echo "Never use --no-verify in this repo."

View File

@@ -2,8 +2,6 @@ name: CI
on:
push:
branches: [main, experiment/*]
pull_request:
branches: [main]
jobs:
@@ -82,16 +80,14 @@ jobs:
# ── 3. Code Health (CodeScene — Hotspot + Average Code Health gates) ──
# Enforces minimum floors on BOTH hotspot and average code health.
# Hotspot: weighted avg of most-edited files (9.6 current | target 9.8)
# Average: project-wide avg across all files (9.37 current | target 9.5)
# Both gates must pass — average catches regressions in non-hotspot files.
- name: Code Health gates (Hotspot ≥9.5 + Average ≥9.0)
# Thresholds come from .codescene-thresholds so CI and local hooks match.
- name: Code Health gates
env:
CODESCENE_PAT: ${{ secrets.CODESCENE_PAT }}
CODESCENE_PROJECT_ID: ${{ secrets.CODESCENE_PROJECT_ID }}
run: |
HOTSPOT_THRESHOLD=9.5
AVERAGE_THRESHOLD=9.33
HOTSPOT_THRESHOLD=$(grep '^HOTSPOT_THRESHOLD=' .codescene-thresholds | cut -d= -f2)
AVERAGE_THRESHOLD=$(grep '^AVERAGE_THRESHOLD=' .codescene-thresholds | cut -d= -f2)
API_RESPONSE=$(curl -sf \
-H "Authorization: Bearer $CODESCENE_PAT" \
-H "Accept: application/json" \

View File

@@ -1,6 +1,43 @@
#!/bin/sh
# Pre-commit: same checks as CI. Fix here, not in CI.
# Pre-commit: fast local gate before commit. Full suite runs in pre-push/CI.
set -e
ensure_node_tooling() {
if command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1; then
return 0
fi
NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$NVM_DIR/nvm.sh" --no-use
nvm use --silent node >/dev/null 2>&1 || true
fi
if ! command -v node >/dev/null 2>&1 || ! command -v pnpm >/dev/null 2>&1; then
echo "❌ node and pnpm must be available before committing"
echo " Install them or make sure your nvm setup is available to git hooks."
exit 1
fi
}
require_main_branch() {
if ! git symbolic-ref -q HEAD >/dev/null 2>&1; then
echo "❌ Commits must happen on main. Detached HEAD is not allowed."
exit 1
fi
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" != "main" ]; then
echo "❌ Commits must happen on main. Current branch: $CURRENT_BRANCH"
echo " Merge or cherry-pick your work onto main, then commit there."
exit 1
fi
}
require_main_branch
ensure_node_tooling
echo "🔍 Pre-commit checks..."
# Lint + types (only if TS files staged)
@@ -19,7 +56,9 @@ echo "✅ Pre-commit passed"
# ── CodeScene Code Health gate ────────────────────────────────────────────
# Blocks commit if scores drop below thresholds in .codescene-thresholds.
# Thresholds are a ratchet — only go up, auto-updated after each successful push.
# Thresholds are a ratchet — only go up.
# When remote scores improve, pre-push updates .codescene-thresholds and stops
# so the new floor is committed with normal verified hooks.
# Note: remote scores lag behind local changes (update after push + re-analysis).
# If check fails: extract hooks, split components, reduce complexity.
# Never use eslint-disable, #[allow(...)], or `as any`.

View File

@@ -28,6 +28,64 @@
# ─────────────────────────────────────────────────────────────────────────
set -e
ensure_node_tooling() {
if command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1; then
return 0
fi
NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
if [ -s "$NVM_DIR/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$NVM_DIR/nvm.sh" --no-use
nvm use --silent node >/dev/null 2>&1 || true
fi
if ! command -v node >/dev/null 2>&1 || ! command -v pnpm >/dev/null 2>&1; then
echo "❌ node and pnpm must be available before pushing"
echo " Install them or make sure your nvm setup is available to git hooks."
exit 1
fi
}
require_main_push() {
if ! git symbolic-ref -q HEAD >/dev/null 2>&1; then
echo "❌ Pushes must happen from main. Detached HEAD is not allowed."
exit 1
fi
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" != "main" ]; then
echo "❌ Pushes must happen from main. Current branch: $CURRENT_BRANCH"
exit 1
fi
while IFS=' ' read -r LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do
[ -z "$LOCAL_REF" ] && continue
case "$LOCAL_REF:$REMOTE_REF" in
refs/heads/main:refs/heads/main)
;;
refs/tags/*:refs/tags/*)
;;
*)
echo "❌ Pushes must be main -> main only."
echo " Attempted: ${LOCAL_REF:-<none>} -> ${REMOTE_REF:-<none>}"
exit 1
;;
esac
done <<EOF
$PUSH_INPUT
EOF
}
if [ -t 0 ]; then
PUSH_INPUT=""
else
PUSH_INPUT=$(cat)
fi
require_main_push
ensure_node_tooling
START_TIME=$(date +%s)
echo ""
@@ -85,7 +143,7 @@ if [ "$RUST_CHANGED" = true ]; then
cargo llvm-cov \
--manifest-path src-tauri/Cargo.toml \
$LLVM_COV_FLAGS \
--ignore-filename-regex "search\.rs|lib\.rs" \
--ignore-filename-regex "lib\.rs|main\.rs|menu\.rs" \
--fail-under-lines 85 \
-- --test-threads=1
echo " ✅ Rust coverage OK"
@@ -93,29 +151,32 @@ else
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
fi
# ── 4. Playwright smoke tests (if any exist) ──────────────────────────
# ── 4. Playwright core smoke lane (if any exist) ──────────────────────
echo ""
SMOKE_FILES=$(find tests/smoke -name '*.spec.ts' 2>/dev/null | head -1)
SMOKE_FILES=$(find tests/smoke tests/integration -name '*.spec.ts' 2>/dev/null | head -1)
if [ -n "$SMOKE_FILES" ]; then
echo "🎭 [4/5] Playwright smoke tests..."
SMOKE_OUTPUT=$(pnpm playwright:smoke 2>&1) || true
echo "🎭 [4/5] Playwright core smoke tests..."
set +e
SMOKE_OUTPUT=$(pnpm playwright:smoke 2>&1)
SMOKE_STATUS=$?
set -e
echo "$SMOKE_OUTPUT" | tail -20
# Fail only on real failures (not flaky). Flaky = passed on retry.
if echo "$SMOKE_OUTPUT" | grep -qE '^\s+\d+ failed' && ! echo "$SMOKE_OUTPUT" | grep -qE '^\s+\d+ passed'; then
echo " ❌ Smoke tests FAILED"
exit 1
if [ "$SMOKE_STATUS" -ne 0 ]; then
echo " ❌ Core smoke tests FAILED"
exit "$SMOKE_STATUS"
fi
echo " ✅ Smoke tests OK"
echo " ✅ Core smoke tests OK"
else
echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)"
echo "⏭️ [4/5] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
fi
# ── 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.
# If remote scores improved, the hook updates the file and stops so the new
# floor is committed with normal verified hooks before the next push.
THRESHOLDS_FILE="$(git rev-parse --show-toplevel)/.codescene-thresholds"
HOTSPOT_MIN=9.5
AVERAGE_MIN=9.31
HOTSPOT_MIN=9.45
AVERAGE_MIN=9.29
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)
@@ -137,8 +198,9 @@ else
else
echo " Remote Hotspot Code Health: $HOTSPOT_SCORE (threshold: $HOTSPOT_MIN)"
echo " Remote Average Code Health: $AVERAGE_SCORE (threshold: $AVERAGE_MIN)"
PYTHON_STATUS=0
python3 -c "
import sys, os
import sys
hotspot = float('$HOTSPOT_SCORE')
average = float('$AVERAGE_SCORE')
@@ -161,8 +223,6 @@ else:
if failed:
sys.exit(1)
# Ratchet: update thresholds file if scores improved
# Truncate (floor) to 2 decimals so the threshold never exceeds the actual score
import math
thresholds_file = '$THRESHOLDS_FILE'
new_hotspot = max(hotspot_min, math.floor(hotspot * 100) / 100)
@@ -171,9 +231,16 @@ 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
sys.exit(3)
" || PYTHON_STATUS=$?
if [ "$PYTHON_STATUS" -ne 0 ] && [ "$PYTHON_STATUS" -ne 3 ]; then
exit "$PYTHON_STATUS"
fi
if [ "$PYTHON_STATUS" -eq 3 ]; then
git add "$THRESHOLDS_FILE"
echo " ❌ Commit the updated .codescene-thresholds with a normal verified commit, then push again."
exit 1
fi
fi
fi

149
AGENTS.md Normal file
View File

@@ -0,0 +1,149 @@
# AGENTS.md — Laputa App
> Quick links: [Project Spec](docs/PROJECT-SPEC.md) · [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
---
## 1. Task Workflow
### 1a. Pick up a task
Run `/laputa-next-task` — fetches next task (To Rework first, then Open), moves to In Progress, returns full description.
**Before writing a single line of code:** run `mcp__codescene__code_health_score` to check the current codebase health against `.codescene-thresholds`. If the score is already below the threshold, **stop and refactor first** — find the worst files with the MCP, improve them, commit, then start the task. Never start feature work on a codebase that is already below the gate.
- Read task description and all comments fully
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
- Check `docs/adr/` for relevant architecture decisions before structural choices
- Add a comment: `🚀 Starting work on this task. [Brief description of approach]`
### 1b. Implement
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
- Commit every 2030 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
- **⛔ NEVER use --no-verify**
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode
### 1c. When done
**Phase 1 — Playwright (only for core user flows):**
Write Playwright test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Tag a test with `@smoke` only if it protects a core pre-push workflow. Do NOT tag cosmetic or mock-heavy checks — keep those in the full regression lane. The curated `pnpm playwright:smoke` suite must stay under **5 minutes**; use `pnpm playwright:regression` for the full Playwright pass.
```bash
pnpm dev --port 5201 &
sleep 3
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```
**Phase 2 — Native app QA:**
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
After both phases pass, add a **completion comment** to the Todoist task before running `/laputa-done`. The comment must include:
- What was implemented (12 lines)
- QA: what was tested and how (Playwright / native screenshot / osascript)
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
- ADRs: any new/updated ADRs (or "none")
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
- Code health: final Hotspot and Average scores after push
Then run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
---
## 2. Development Process
### Commits & pushes
- Push directly to `main` — no PRs, no branches. Pre-push blocks non-`main` pushes.
- Pre-push hook runs full check suite (build + tests + core Playwright smoke + CodeScene)
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. **⛔ NEVER use --no-verify**
### TDD (mandatory)
Red → Green → Refactor → Commit. One cycle per commit. For bugs: write failing regression test first, then fix. Exception: pure CSS/layout changes.
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows.
### Code health (mandatory)
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up. When pre-push sees improved remote scores, it updates `.codescene-thresholds`, stages it, and stops so you can commit the new floor with normal verified hooks before pushing again. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
**⛔ NEVER edit `.codescene-thresholds` to lower the values.** If the gate blocks you, improve the code — do not lower the bar.
**Before every commit:** run `mcp__codescene__code_health_review` on files you touched and verify score is higher. **Boy Scout Rule:** every file you touch must leave with a higher score.
**If CodeScene gate blocks your push:** use `mcp__codescene__code_health_score` to find the worst file, refactor it, commit, push again. Do NOT stop or wait for laputa-refactor — that is a background loop, not a substitute for fixing your own regressions.
### 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
```
### ADRs & docs
ADRs live in `docs/adr/`. Create in the same commit as the code. Never edit existing — create a new one that supersedes. Use `/create-adr`. **When:** new dependency, storage strategy, platform target, core abstraction, cross-cutting pattern. **Not for:** bug fixes, styling, refactors.
After any 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.
---
## 3. Product Rules
### User vault (`~/Laputa/`)
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
### UI design
Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task; on completion merge into `ui-design.pen` and delete it.
### UI components — mandatory rules
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
| Need | Use |
|---|---|
| Text input | `Input` from shadcn/ui |
| Dropdown/select | `Select` from shadcn/ui |
| Date picker | `Calendar` + `Popover` from shadcn/ui (NOT native `<input type="date">`) |
| Button | `Button` from shadcn/ui |
| Autocomplete/combobox | Reuse existing combobox components from the app (check `src/components/`) |
| Wikilink picker | Reuse the wikilink autocomplete component already used in the editor and Properties panel |
| Emoji picker | Reuse the emoji picker component already used for note/type icons |
| Color picker | Reuse the color swatch picker used for type customization |
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
| Dialog/modal | `Dialog` from shadcn/ui |
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Laputa — if it looks like a browser default, it's wrong.
---
## 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
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
```
### Diagrams
Prefer Mermaid (`flowchart`, `sequenceDiagram`, `classDiagram`, `stateDiagram-v2`). ASCII only for spatial wireframe layouts.

150
CLAUDE.md
View File

@@ -1,149 +1,3 @@
# CLAUDE.md — Laputa App
@AGENTS.md
> Quick links: [Project Spec](docs/PROJECT-SPEC.md) · [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
---
## 1. Task Workflow
### 1a. Pick up a task
Run `/laputa-next-task` — fetches next task (To Rework first, then Open), moves to In Progress, returns full description.
**Before writing a single line of code:** run `mcp__codescene__code_health_score` to check the current codebase health against `.codescene-thresholds`. If the score is already below the threshold, **stop and refactor first** — find the worst files with the MCP, improve them, commit, then start the task. Never start feature work on a codebase that is already below the gate.
- Read task description and all comments fully
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
- Check `docs/adr/` for relevant architecture decisions before structural choices
- Add a comment: `🚀 Starting work on this task. [Brief description of approach]`
### 1b. Implement
- Work on `main` branch — **no branches, no PRs, ever**
- Commit every 2030 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
- **⛔ NEVER use --no-verify**
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode
### 1c. When done
**Phase 1 — Playwright (only for core user flows):**
Write smoke test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Do NOT write Playwright tests for cosmetic changes — use Vitest instead. Suite must stay under **10 minutes**.
```bash
pnpm dev --port 5201 &
sleep 3
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```
**Phase 2 — Native app QA:**
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
After both phases pass, add a **completion comment** to the Todoist task before running `/laputa-done`. The comment must include:
- What was implemented (12 lines)
- QA: what was tested and how (Playwright / native screenshot / osascript)
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
- ADRs: any new/updated ADRs (or "none")
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
- Code health: final Hotspot and Average scores after push
Then run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
---
## 2. Development Process
### Commits & pushes
- Push directly to `main` — no PRs, no branches
- Pre-push hook runs full check suite (build + tests + Playwright + CodeScene)
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. **⛔ NEVER use --no-verify**
### TDD (mandatory)
Red → Green → Refactor → Commit. One cycle per commit. For bugs: write failing regression test first, then fix. Exception: pure CSS/layout changes.
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows.
### Code health (mandatory)
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up, auto-updated after each successful push. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
**⛔ NEVER edit `.codescene-thresholds` to lower the values.** If the gate blocks you, improve the code — do not lower the bar.
**Before every commit:** run `mcp__codescene__code_health_review` on files you touched and verify score is higher. **Boy Scout Rule:** every file you touch must leave with a higher score.
**If CodeScene gate blocks your push:** use `mcp__codescene__code_health_score` to find the worst file, refactor it, commit, push again. Do NOT stop or wait for laputa-refactor — that is a background loop, not a substitute for fixing your own regressions.
### 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
```
### ADRs & docs
ADRs live in `docs/adr/`. Create in the same commit as the code. Never edit existing — create a new one that supersedes. Use `/create-adr`. **When:** new dependency, storage strategy, platform target, core abstraction, cross-cutting pattern. **Not for:** bug fixes, styling, refactors.
After any 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.
---
## 3. Product Rules
### User vault (`~/Laputa/`)
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
### UI design
Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task; on completion merge into `ui-design.pen` and delete it.
### UI components — mandatory rules
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
| Need | Use |
|---|---|
| Text input | `Input` from shadcn/ui |
| Dropdown/select | `Select` from shadcn/ui |
| Date picker | `Calendar` + `Popover` from shadcn/ui (NOT native `<input type="date">`) |
| Button | `Button` from shadcn/ui |
| Autocomplete/combobox | Reuse existing combobox components from the app (check `src/components/`) |
| Wikilink picker | Reuse the wikilink autocomplete component already used in the editor and Properties panel |
| Emoji picker | Reuse the emoji picker component already used for note/type icons |
| Color picker | Reuse the color swatch picker used for type customization |
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
| Dialog/modal | `Dialog` from shadcn/ui |
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Laputa — if it looks like a browser default, it's wrong.
---
## 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
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
```
### Diagrams
Prefer Mermaid (`flowchart`, `sequenceDiagram`, `classDiagram`, `stateDiagram-v2`). ASCII only for spatial wireframe layouts.
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.

View File

@@ -24,9 +24,6 @@ Personal knowledge and life management desktop app built with Tauri v2 + React +
# Install dependencies
pnpm install
# Install git hooks (optional but recommended)
.github/hooks/install-hooks.sh
# Run dev server
pnpm dev
@@ -69,7 +66,7 @@ claude 'Check code health with CodeScene MCP'
## Development Workflow
See [CLAUDE.md](CLAUDE.md) for coding guidelines and workflow.
See [AGENTS.md](AGENTS.md) for coding guidelines and workflow. [CLAUDE.md](CLAUDE.md) remains as a compatibility shim for Claude Code.
**Key principles:**
- Small, atomic commits
@@ -79,7 +76,7 @@ See [CLAUDE.md](CLAUDE.md) for coding guidelines and workflow.
## CI/CD
GitHub Actions runs on every push/PR:
GitHub Actions runs on every push to `main`:
- ✅ Tests (frontend + Rust)
- 📊 Coverage (70% threshold)
- 🎨 Lint & format
@@ -89,7 +86,7 @@ See [.github/SETUP.md](.github/SETUP.md) for CI/CD configuration.
## Git Hooks
Pre-commit hook checks code health before every commit. See [.github/HOOKS.md](.github/HOOKS.md) for details.
Husky installs the git hooks from `.husky/` during `pnpm install`. The repo enforces `main`-only commits and pushes; see [.github/HOOKS.md](.github/HOOKS.md) for details.
## License

View File

@@ -665,7 +665,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `get_build_number` | Get app build number |
| `save_image` | Save base64 image to vault |
| `copy_image_to_vault` | Copy image file to vault |
| `update_menu_state` | Update native menu checkmarks |
| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions |
## Mock Layer
@@ -718,6 +718,8 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent note actions are wired through both the command palette and the native Note menu. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled.
## Auto-Release & In-App Updates
### Release Pipeline

View File

@@ -24,7 +24,8 @@ pnpm tauri dev
# Run tests
pnpm test # Vitest unit tests
cargo test # Rust tests (from src-tauri/)
pnpm playwright:smoke # Playwright smoke tests
pnpm playwright:smoke # Curated Playwright core smoke lane (~5 min)
pnpm playwright:regression # Full Playwright regression suite
```
## Directory Structure
@@ -167,7 +168,7 @@ laputa-app/
│ └── package.json
├── e2e/ # Playwright E2E tests (~26 specs)
├── tests/smoke/ # Smoke tests (~10 specs)
├── tests/smoke/ # Playwright specs (full regression + @smoke subset)
├── design/ # Per-task design files
├── demo-vault-v2/ # Getting Started demo vault
├── scripts/ # Build/utility scripts
@@ -175,9 +176,11 @@ laputa-app/
├── package.json # Frontend dependencies + scripts
├── vite.config.ts # Vite bundler config
├── tsconfig.json # TypeScript config
├── playwright.config.ts # E2E test config
├── playwright.config.ts # Full Playwright regression config
├── playwright.smoke.config.ts # Curated pre-push Playwright config
├── ui-design.pen # Master design file
├── CLAUDE.md # Project instructions
├── AGENTS.md # Shared project instructions for coding agents
├── CLAUDE.md # Claude Code compatibility shim importing AGENTS.md
└── docs/ # This documentation
```
@@ -279,6 +282,8 @@ type SidebarSelection =
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. The native macOS menu bar also triggers commands via `useMenuEvents`.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
## Running Tests
```bash
@@ -294,9 +299,12 @@ cargo test
# Rust coverage (must pass ≥85% line coverage)
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
# Playwright smoke tests (requires dev server)
# Playwright core smoke lane (requires dev server)
BASE_URL="http://localhost:5173" pnpm playwright:smoke
# Full Playwright regression suite
BASE_URL="http://localhost:5173" pnpm playwright:regression
# Single Playwright test
BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
```
@@ -330,6 +338,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. Register the command in `useAppCommands.ts` via the command registry
2. Add a corresponding menu bar item in `menu.rs` for discoverability
3. If it has a keyboard shortcut, register it in `useAppKeyboard.ts`
4. If its enabled state depends on runtime selection (active note, deleted preview, Git status, etc.), thread that flag through `useMenuEvents.ts` and `update_menu_state` so the native menu enables/disables correctly
### Modify styling

View File

@@ -0,0 +1,81 @@
# AGENTS.md — Laputa Vault
This is a [Laputa](https://github.com/refactoringhq/laputa-app) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
## Note structure
Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field.
```yaml
---
is_a: TypeName # the note's type (must match the title of a type file in the vault)
url: https://... # example property
belongs_to: "[[other-note]]"
related_to:
- "[[note-a]]"
- "[[note-b]]"
---
# Note Title
Body content in markdown.
```
System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked.
## Types
A type is a note with `is_a: Type`. Type files live in the vault root:
```yaml
---
is_a: Type
_icon: books # Phosphor icon name in kebab-case
_color: "#8b5cf6" # hex color
---
# TypeName
```
To find what types exist: look for files with `is_a: Type` in the vault root.
## Relationships
Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically.
Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid.
## Wikilinks
- `[[filename]]` or `[[Note Title]]` — link by filename or title
- `[[filename|display text]]` — with custom display text
- Works in frontmatter values and markdown body
## Views
Saved filters live in `views/` as `.view.json` files:
```json
{
"title": "Active Notes",
"filters": [
{"property": "is_a", "operator": "equals", "value": "Note"},
{"property": "status", "operator": "equals", "value": "Active"}
],
"sort": {"property": "title", "direction": "asc"}
}
```
## Filenames
Use kebab-case: `my-note-title.md`. One note per file.
## What you can do
- Create/edit notes with correct frontmatter and H1 title
- Create new type files
- Add or modify relationships
- Create/edit views in `views/`
- Edit `AGENTS.md` (this file)
Do not modify app configuration files — those are local to each installation.

View File

@@ -1,81 +1,3 @@
# CLAUDE.md — Laputa Vault
@AGENTS.md
This is a [Laputa](https://github.com/refactoringhq/laputa-app) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
## Note structure
Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field.
```yaml
---
is_a: TypeName # the note's type (must match the title of a type file in the vault)
url: https://... # example property
belongs_to: "[[other-note]]"
related_to:
- "[[note-a]]"
- "[[note-b]]"
---
# Note Title
Body content in markdown.
```
System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked.
## Types
A type is a note with `is_a: Type`. Type files live in the vault root:
```yaml
---
is_a: Type
_icon: books # Phosphor icon name in kebab-case
_color: "#8b5cf6" # hex color
---
# TypeName
```
To find what types exist: look for files with `is_a: Type` in the vault root.
## Relationships
Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically.
Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid.
## Wikilinks
- `[[filename]]` or `[[Note Title]]` — link by filename or title
- `[[filename|display text]]` — with custom display text
- Works in frontmatter values and markdown body
## Views
Saved filters live in `views/` as `.view.json` files:
```json
{
"title": "Active Notes",
"filters": [
{"property": "is_a", "operator": "equals", "value": "Note"},
{"property": "status", "operator": "equals", "value": "Active"}
],
"sort": {"property": "title", "direction": "asc"}
}
```
## Filenames
Use kebab-case: `my-note-title.md`. One note per file.
## What you can do
- Create/edit notes with correct frontmatter and H1 title
- Create new type files
- Add or modify relationships
- Create/edit views in `views/`
- Edit `CLAUDE.md` (this file)
Do not modify app configuration files — those are local to each installation.
This file is a Claude Code compatibility shim. Keep shared vault instructions in `AGENTS.md`.

View File

@@ -12,7 +12,7 @@ Laputa integrates with [Claude Code](https://docs.anthropic.com/claude-code) —
claude "Create a note for the book Zero to One by Peter Thiel, with a rating and a topic"
```
Claude understands Laputa's format (frontmatter, types, wikilinks, relationships) and creates or edits files accordingly. Your vault's `CLAUDE.md` file gives it full context.
Claude understands Laputa's format (frontmatter, types, wikilinks, relationships) and creates or edits files accordingly. Your vault's `AGENTS.md` file gives any coding agent full context, and `CLAUDE.md` imports it for Claude Code compatibility.
## Git sync

View File

@@ -13,7 +13,8 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test tests/smoke/",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/raw-editor-sync-to-blocknote.spec.ts tests/integration/vault-workflows.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "vitest run --coverage",
"prepare": "husky"

View File

@@ -0,0 +1,22 @@
import { defineConfig } from '@playwright/test'
const baseURL = process.env.BASE_URL || 'http://localhost:5201'
const port = process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'
export default defineConfig({
testDir: './tests',
timeout: 30_000,
retries: 1,
workers: 1,
grep: /@smoke/,
use: {
baseURL,
headless: true,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {
command: `pnpm dev --port ${port}`,
url: baseURL,
reuseExistingServer: true,
},
})

View File

@@ -48,6 +48,7 @@ pub fn update_menu_state(
has_active_note: bool,
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
has_restorable_deleted_note: Option<bool>,
) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, has_active_note);
if let Some(v) = has_modified_files {
@@ -56,6 +57,9 @@ pub fn update_menu_state(
if let Some(v) = has_conflicts {
menu::set_git_conflict_items_enabled(&app_handle, v);
}
if let Some(v) = has_restorable_deleted_note {
menu::set_restore_deleted_item_enabled(&app_handle, v);
}
Ok(())
}
@@ -66,6 +70,7 @@ pub fn update_menu_state(
_has_active_note: bool,
_has_modified_files: Option<bool>,
_has_conflicts: Option<bool>,
_has_restorable_deleted_note: Option<bool>,
) -> Result<(), String> {
Ok(())
}

View File

@@ -38,6 +38,7 @@ const GO_INBOX: &str = "go-inbox";
const NOTE_ARCHIVE: &str = "note-archive";
const NOTE_DELETE: &str = "note-delete";
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
const NOTE_RESTORE_DELETED: &str = "note-restore-deleted";
const VAULT_OPEN: &str = "vault-open";
const VAULT_REMOVE: &str = "vault-remove";
@@ -79,6 +80,7 @@ const CUSTOM_IDS: &[&str] = &[
NOTE_ARCHIVE,
NOTE_DELETE,
NOTE_OPEN_IN_NEW_WINDOW,
NOTE_RESTORE_DELETED,
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
@@ -102,6 +104,9 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
NOTE_OPEN_IN_NEW_WINDOW,
];
/// IDs of menu items that depend on a deleted-note preview being active.
const RESTORE_DELETED_DEPENDENT_IDS: &[&str] = &[NOTE_RESTORE_DELETED];
/// IDs of menu items that depend on having uncommitted changes.
const GIT_COMMIT_DEPENDENT_IDS: &[&str] = &[VAULT_COMMIT_PUSH];
@@ -276,6 +281,10 @@ fn build_note_menu(app: &App) -> MenuResult {
.id(NOTE_DELETE)
.accelerator("CmdOrCtrl+Backspace")
.build(app)?;
let restore_deleted_note = MenuItemBuilder::new("Restore Deleted Note")
.id(NOTE_RESTORE_DELETED)
.enabled(false)
.build(app)?;
let open_new_window = MenuItemBuilder::new("Open in New Window")
.id(NOTE_OPEN_IN_NEW_WINDOW)
.accelerator("CmdOrCtrl+Shift+O")
@@ -295,6 +304,7 @@ fn build_note_menu(app: &App) -> MenuResult {
Ok(SubmenuBuilder::new(app, "Note")
.item(&archive_note)
.item(&delete_note)
.item(&restore_deleted_note)
.separator()
.item(&open_new_window)
.separator()
@@ -421,6 +431,11 @@ pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_CONFLICT_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on a deleted note preview being active.
pub fn set_restore_deleted_item_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, RESTORE_DELETED_DEPENDENT_IDS, enabled);
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Sidebar } from './components/Sidebar'
import { NoteList } from './components/NoteList'
import type { DeletedNoteEntry } from './components/note-list/noteListUtils'
import { Editor } from './components/Editor'
import { ResizeHandle } from './components/ResizeHandle'
import { CreateTypeDialog } from './components/CreateTypeDialog'
@@ -59,6 +60,7 @@ import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
import { GitRequiredModal } from './components/GitRequiredModal'
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
import { trackEvent } from './lib/telemetry'
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
import './App.css'
// Type declarations for mock content storage and test overrides
@@ -313,18 +315,48 @@ function App() {
}, [resolvedPath])
const handleDiscardFile = useCallback(async (relativePath: string) => {
const targetFile = vault.modifiedFiles.find((file) => file.relativePath === relativePath)
const activePathBefore = notes.activeTabPath
try {
if (isTauri()) {
await invoke('git_discard_file', { vaultPath: resolvedPath, relativePath })
} else {
await mockInvoke('git_discard_file', { vaultPath: resolvedPath, relativePath })
}
await vault.loadModifiedFiles()
await vault.reloadVault()
const reloadedEntries = await vault.reloadVault()
const affectedActiveTab = !!activePathBefore
&& (activePathBefore === targetFile?.path || activePathBefore.endsWith('/' + relativePath))
if (!affectedActiveTab) return
const refreshedEntry = reloadedEntries.find((entry) =>
entry.path === targetFile?.path || entry.path.endsWith('/' + relativePath),
)
if (refreshedEntry) {
await notes.handleReplaceActiveTab(refreshedEntry)
} else {
notes.closeAllTabs()
}
} catch (err) {
setToastMessage(typeof err === 'string' ? err : 'Failed to discard changes')
}
}, [resolvedPath, vault, setToastMessage])
}, [resolvedPath, vault, notes, setToastMessage])
const handleOpenDeletedNote = useCallback(async (entry: DeletedNoteEntry) => {
let previewContent = 'Content not available (untracked)'
let hasDiff = false
try {
const diff = await vault.loadDiff(entry.path)
hasDiff = diff.length > 0
previewContent = extractDeletedContentFromDiff(diff) ?? previewContent
} catch (err) {
console.warn('Failed to load deleted note preview:', err)
}
notes.openTabWithContent(entry, previewContent)
if (hasDiff) {
setTimeout(() => diffToggleRef.current(), 50)
} else {
setToastMessage('Content not available (untracked)')
}
}, [vault, notes, setToastMessage])
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
@@ -398,17 +430,6 @@ function App() {
return [...builtIn, ...Array.from(customFields).sort()]
}, [vault.entries])
const valueSuggestionsForField = useCallback((field: string): string[] => {
if (!vault.entries?.length) return []
const values = new Set<string>()
for (const e of vault.entries) {
if (field === 'type' && e.isA) values.add(e.isA)
else if (field === 'status' && e.status) values.add(e.status)
else if (e.properties?.[field] != null) values.add(String(e.properties[field]))
}
return Array.from(values).sort()
}, [vault.entries])
const bulkActions = useBulkActions(entryActions, setToastMessage)
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
@@ -451,6 +472,15 @@ function App() {
}
}, [resolvedPath, vault, setToastMessage])
const activeDeletedFile = useMemo(() => {
const activeTabPath = notes.activeTabPath
if (!activeTabPath) return null
return vault.modifiedFiles.find((file) =>
file.status === 'deleted'
&& (file.path === activeTabPath || activeTabPath.endsWith('/' + file.relativePath)),
) ?? null
}, [notes.activeTabPath, vault.modifiedFiles])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
entries: vault.entries,
@@ -473,7 +503,7 @@ function App() {
onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: () => rawToggleRef.current(),
onToggleRawEditor: activeDeletedFile ? undefined : () => rawToggleRef.current(),
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
zoomLevel: zoom.zoomLevel,
onSelect: handleSetSelection,
@@ -506,6 +536,8 @@ function App() {
onOpenInNewWindow: handleOpenInNewWindow,
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: entryActions.handleToggleOrganized,
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
canRestoreDeletedNote: !!activeDeletedFile,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
@@ -518,7 +550,7 @@ function App() {
return filtered.map(e => ({
path: e.path, title: e.title, type: e.isA ?? 'Note',
}))
}, [vault.entries, selection, inboxPeriod])
}, [vault.entries, vault.views, selection, inboxPeriod])
const aiNoteListFilter = useMemo(() => {
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
@@ -585,7 +617,7 @@ function App() {
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} views={vault.views} visibleNotesRef={visibleNotesRef} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} views={vault.views} visibleNotesRef={visibleNotesRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -618,14 +650,14 @@ function App() {
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onToggleFavorite={entryActions.handleToggleFavorite}
onToggleOrganized={entryActions.handleToggleOrganized}
onDeleteNote={deleteActions.handleDeleteNote}
onArchiveNote={entryActions.handleArchiveNote}
onUnarchiveNote={entryActions.handleUnarchiveNote}
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
onToggleOrganized={activeDeletedFile ? undefined : entryActions.handleToggleOrganized}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={appSave.handleContentChange}
onSave={appSave.handleSave}
onTitleSync={appSave.handleTitleSync}
onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
@@ -636,8 +668,8 @@ function App() {
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
onSetNoteIcon={handleSetNoteIcon}
onRemoveNoteIcon={handleRemoveNoteIcon}
onSetNoteIcon={activeDeletedFile ? undefined : handleSetNoteIcon}
onRemoveNoteIcon={activeDeletedFile ? undefined : handleRemoveNoteIcon}
isConflicted={conflictFlow.isConflicted}
onKeepMine={conflictFlow.handleKeepMine}
onKeepTheirs={conflictFlow.handleKeepTheirs}
@@ -652,7 +684,7 @@ function App() {
<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} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} entries={vault.entries} editingView={dialogs.editingView?.definition ?? null} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<ConflictResolverModal
open={dialogs.showConflictResolver}

View File

@@ -1,49 +1,49 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { FilterBuilder } from './FilterBuilder'
import { EmojiPicker } from './EmojiPicker'
import type { FilterGroup, ViewDefinition, VaultEntry } from '../types'
import type { FilterGroup, ViewDefinition } from '../types'
interface CreateViewDialogProps {
open: boolean
onClose: () => void
onCreate: (definition: ViewDefinition) => void
availableFields: string[]
/** Returns known values for a given field (for autocomplete). */
valueSuggestions?: (field: string) => string[]
/** Vault entries for wikilink autocomplete in filter value fields. */
entries?: VaultEntry[]
/** When provided, the dialog operates in edit mode with pre-populated fields. */
editingView?: ViewDefinition | null
}
export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions, entries, editingView }: CreateViewDialogProps) {
const [name, setName] = useState('')
const [icon, setIcon] = useState('')
interface CreateViewDialogFormProps {
availableFields: string[]
initialName: string
initialIcon: string
initialFilters: FilterGroup
isEditing: boolean
onClose: () => void
onCreate: (definition: ViewDefinition) => void
}
function CreateViewDialogForm({
availableFields,
initialName,
initialIcon,
initialFilters,
isEditing,
onClose,
onCreate,
}: CreateViewDialogFormProps) {
const [name, setName] = useState(initialName)
const [icon, setIcon] = useState(initialIcon)
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [filters, setFilters] = useState<FilterGroup>({
all: [{ field: 'type', op: 'equals', value: '' }],
})
const [filters, setFilters] = useState<FilterGroup>(initialFilters)
const inputRef = useRef<HTMLInputElement>(null)
const isEditing = !!editingView
useEffect(() => {
if (open) {
if (editingView) {
setName(editingView.name) // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
setIcon(editingView.icon ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
setFilters(editingView.filters) // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
} else {
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setFilters({ all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
}
setShowEmojiPicker(false) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, availableFields, editingView])
const timeoutId = window.setTimeout(() => inputRef.current?.focus(), 50)
return () => window.clearTimeout(timeoutId)
}, [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
@@ -69,53 +69,77 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
setShowEmojiPicker(false)
}, [])
const isCreateDisabled = !name.trim()
return (
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex gap-2">
<div className="w-16 space-y-1.5 relative">
<label className="text-xs font-medium text-muted-foreground">Icon</label>
<button
type="button"
className="flex h-9 w-full items-center justify-center rounded-md border border-input bg-background text-xl cursor-pointer hover:bg-accent transition-colors"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
title="Pick icon"
>
{icon || <span className="text-sm text-muted-foreground">📋</span>}
</button>
{showEmojiPicker && (
<EmojiPicker onSelect={handleSelectEmoji} onClose={handleCloseEmojiPicker} />
)}
</div>
<div className="flex-1 space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Name</label>
<Input
ref={inputRef}
placeholder="e.g. Active Projects, Reading List..."
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto">
<label className="text-xs font-medium text-muted-foreground">Filters</label>
<FilterBuilder
group={filters}
onChange={setFilters}
availableFields={availableFields}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={isCreateDisabled}>{isEditing ? 'Save' : 'Create'}</Button>
</DialogFooter>
</form>
)
}
export function CreateViewDialog({ open, onClose, onCreate, availableFields, editingView }: CreateViewDialogProps) {
const isEditing = !!editingView
const initialFilters = editingView?.filters ?? { all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }
const formKey = editingView ? `edit:${editingView.name}` : `create:${availableFields[0] ?? 'type'}`
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent showCloseButton={false} className="flex max-h-[80vh] flex-col sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit View' : 'Create View'}</DialogTitle>
<DialogDescription className="sr-only">
{isEditing ? 'Update the name, icon, and filters for this saved view.' : 'Create a saved view by choosing a name, icon, and filter rules.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex gap-2">
<div className="w-16 space-y-1.5 relative">
<label className="text-xs font-medium text-muted-foreground">Icon</label>
<button
type="button"
className="flex h-9 w-full items-center justify-center rounded-md border border-input bg-background text-xl cursor-pointer hover:bg-accent transition-colors"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
title="Pick icon"
>
{icon || <span className="text-sm text-muted-foreground">📋</span>}
</button>
{showEmojiPicker && (
<EmojiPicker onSelect={handleSelectEmoji} onClose={handleCloseEmojiPicker} />
)}
</div>
<div className="flex-1 space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Name</label>
<Input
ref={inputRef}
placeholder="e.g. Active Projects, Reading List..."
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto">
<label className="text-xs font-medium text-muted-foreground">Filters</label>
<FilterBuilder
group={filters}
onChange={setFilters}
availableFields={availableFields}
valueSuggestions={valueSuggestions}
entries={entries}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={!name.trim()}>{isEditing ? 'Save' : 'Create'}</Button>
</DialogFooter>
</form>
{open && (
<CreateViewDialogForm
key={formKey}
availableFields={availableFields}
initialName={editingView?.name ?? ''}
initialIcon={editingView?.icon ?? ''}
initialFilters={initialFilters}
isEditing={isEditing}
onClose={onClose}
onCreate={onCreate}
/>
)}
</DialogContent>
</Dialog>
)

View File

@@ -168,6 +168,7 @@ export function EditorContent({
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
const hasH1 = freshEntry?.hasH1 ?? activeTab?.entry.hasH1 ?? false
const isDeletedPreview = !!activeTab && !freshEntry
// Non-markdown text files always use the raw editor (no BlockNote)
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
const effectiveRawMode = rawMode || isNonMarkdownText
@@ -177,7 +178,7 @@ export function EditorContent({
const isUntitledDraft = !!activeTab
&& activeTab.entry.filename.startsWith('untitled-')
&& (activeStatus === 'new' || activeStatus === 'unsaved' || activeStatus === 'pendingSave')
const showTitleSection = !hasH1 && !isUntitledDraft
const showTitleSection = !isDeletedPreview && !hasH1 && !isUntitledDraft
const titleSectionRef = useRef<HTMLDivElement | null>(null)
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
@@ -222,7 +223,7 @@ export function EditorContent({
<ActiveTabBreadcrumb
activeTab={activeTab}
barRef={breadcrumbBarRef}
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, forceRawMode: isNonMarkdownText, ...breadcrumbProps }}
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, forceRawMode: isNonMarkdownText || isDeletedPreview, ...breadcrumbProps }}
/>
{isArchived && breadcrumbProps.onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(path)} />
@@ -263,7 +264,7 @@ export function EditorContent({
</>
)}
</div>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable />
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable={!isDeletedPreview} />
</div>
</div>
)}

View File

@@ -1,48 +1,16 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { FilterBuilder } from './FilterBuilder'
import type { FilterGroup, VaultEntry } from '../types'
import type { FilterGroup } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
...overrides,
})
const entries: VaultEntry[] = [
makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha Project', isA: 'Project' }),
makeEntry({ path: '/vault/person/luca.md', filename: 'luca.md', title: 'Luca', isA: 'Person' }),
makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI Research', isA: 'Topic' }),
makeEntry({ path: '/vault/note/plain.md', filename: 'plain.md', title: 'Plain Note', isA: null }),
makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice Smith', isA: 'Person', aliases: ['Alice'] }),
]
describe('FilterBuilder wikilink autocomplete', () => {
describe('FilterBuilder value inputs', () => {
const onChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
function renderWithEntries(group?: FilterGroup) {
function renderBuilder(group?: FilterGroup) {
const defaultGroup: FilterGroup = {
all: [{ field: 'title', op: 'contains', value: '' }],
}
@@ -51,159 +19,67 @@ describe('FilterBuilder wikilink autocomplete', () => {
group={group ?? defaultGroup}
onChange={onChange}
availableFields={['type', 'status', 'title']}
entries={entries}
/>,
)
}
it('renders value input with wikilink support when entries are provided', () => {
renderWithEntries()
it('renders a plain text input for text operators', () => {
renderBuilder()
expect(screen.getByTestId('filter-value-input')).toBeInTheDocument()
expect(screen.getByTestId('filter-value-input')).toHaveAttribute('placeholder', 'value')
})
it('does not show dropdown for plain text input', () => {
renderWithEntries({
all: [{ field: 'title', op: 'contains', value: 'hello' }],
it('keeps wikilink-style values in the plain text input without opening a dropdown', () => {
renderBuilder({
all: [{ field: 'belongs to', op: 'contains', value: '[[Alpha Project]]' }],
})
const input = screen.getByTestId('filter-value-input')
fireEvent.focus(input)
expect(input).toHaveValue('[[Alpha Project]]')
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
})
it('shows dropdown when value starts with [[', () => {
renderWithEntries({
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
})
const input = screen.getByTestId('filter-value-input')
fireEvent.focus(input)
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
expect(screen.getByText('Alpha Project')).toBeInTheDocument()
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
})
it('updates filter values as raw text strings', () => {
renderBuilder()
it('does not show dropdown for short queries after [[', () => {
renderWithEntries({
all: [{ field: 'title', op: 'contains', value: '[[A' }],
fireEvent.change(screen.getByTestId('filter-value-input'), {
target: { value: 'plain text' },
})
const input = screen.getByTestId('filter-value-input')
fireEvent.focus(input)
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
})
it('inserts [[stem|title]] when a note is selected', () => {
renderWithEntries({
all: [{ field: 'title', op: 'contains', value: '[[Alpha' }],
})
const input = screen.getByTestId('filter-value-input')
fireEvent.focus(input)
fireEvent.click(screen.getByText('Alpha Project'))
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
all: [{ field: 'title', op: 'contains', value: '[[alpha|Alpha Project]]' }],
all: [{ field: 'title', op: 'contains', value: 'plain text' }],
}),
)
})
it('navigates dropdown with arrow keys and selects with Enter', () => {
renderWithEntries({
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
it('does not render a value input for empty-check operators', () => {
renderBuilder({
all: [{ field: 'title', op: 'is_empty' }],
})
const input = screen.getByTestId('filter-value-input')
fireEvent.focus(input)
fireEvent.keyDown(input, { key: 'ArrowDown' })
const selected = document.querySelector('.wikilink-menu__item--selected')
expect(selected).toBeTruthy()
fireEvent.keyDown(input, { key: 'Enter' })
expect(onChange).toHaveBeenCalled()
})
it('closes dropdown on Escape', () => {
renderWithEntries({
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
})
const input = screen.getByTestId('filter-value-input')
fireEvent.focus(input)
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
fireEvent.keyDown(input, { key: 'Escape' })
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
})
it('matches on aliases', () => {
renderWithEntries({
all: [{ field: 'title', op: 'contains', value: '[[Alice' }],
})
const input = screen.getByTestId('filter-value-input')
fireEvent.focus(input)
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
})
it('shows type badge for typed entries', () => {
const personType = makeEntry({
path: '/vault/person.md', filename: 'person.md', title: 'Person',
isA: 'Type', color: 'yellow', icon: 'user',
})
const entriesWithType = [...entries, personType]
render(
<FilterBuilder
group={{ all: [{ field: 'title', op: 'contains', value: '[[Luca' }] }}
onChange={onChange}
availableFields={['type', 'status', 'title']}
entries={entriesWithType}
/>,
)
const input = screen.getByTestId('filter-value-input')
fireEvent.focus(input)
expect(screen.getByText('Person')).toBeInTheDocument()
})
it('opens dropdown on typing [[ in input', () => {
renderWithEntries({
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
})
const input = screen.getByTestId('filter-value-input')
// Simulate the user typing [[ — dropdown opens when value starts with [[
fireEvent.change(input, { target: { value: '[[Al' } })
// The internal open state is set by onChange, verified via focus re-trigger
fireEvent.focus(input)
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
})
it('plain text without [[ still works as regular input', () => {
renderWithEntries()
const input = screen.getByTestId('filter-value-input')
fireEvent.change(input, { target: { value: 'some text' } })
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
expect(onChange).toHaveBeenCalled()
})
it('falls back to plain input when no entries are provided', () => {
render(
<FilterBuilder
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
onChange={onChange}
availableFields={['type', 'status', 'title']}
/>,
)
const input = screen.getByPlaceholderText('value')
expect(input).toBeInTheDocument()
expect(input).not.toHaveAttribute('data-testid', 'filter-value-input')
expect(screen.queryByTestId('filter-value-input')).not.toBeInTheDocument()
})
it('renders calendar date picker button for date operators', () => {
renderWithEntries({
renderBuilder({
all: [{ field: 'created', op: 'before', value: '2024-06-01' }],
})
const dateButton = screen.getByTestId('date-picker-trigger')
expect(dateButton).toBeInTheDocument()
expect(dateButton).toHaveTextContent('Jun 1, 2024')
// Should NOT have a native input type="date"
expect(screen.queryByDisplayValue('2024-06-01')).not.toBeInTheDocument()
})
it('renders date picker placeholder when no date is selected', () => {
renderWithEntries({
renderBuilder({
all: [{ field: 'created', op: 'after', value: '' }],
})
const dateButton = screen.getByTestId('date-picker-trigger')
expect(dateButton).toHaveTextContent('Pick a date')
expect(screen.getByTestId('date-picker-trigger')).toHaveTextContent('Pick a date')
})
it('shows body field in field dropdown separated from property fields', () => {
@@ -214,7 +90,7 @@ describe('FilterBuilder wikilink autocomplete', () => {
availableFields={['type', 'status', 'body']}
/>,
)
// Body field should be selected as the current value
expect(screen.getByText('body')).toBeInTheDocument()
})
})

View File

@@ -1,5 +1,3 @@
import { useState, useRef, useMemo, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X, CalendarBlank } from '@phosphor-icons/react'
import { format, parseISO } from 'date-fns'
import { Button } from '@/components/ui/button'
@@ -7,10 +5,7 @@ import { Input } from '@/components/ui/input'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { FilterCondition, FilterOp, FilterGroup, FilterNode, VaultEntry } from '../types'
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import './WikilinkSuggestionMenu.css'
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
const OPERATORS: { value: FilterOp; label: string }[] = [
{ value: 'equals', label: 'equals' },
@@ -100,210 +95,6 @@ function OperatorSelect({ value, onChange }: {
)
}
const MAX_WIKILINK_RESULTS = 10
const MIN_WIKILINK_QUERY = 2
function entryMatchesQuery(e: VaultEntry, lowerQuery: string): boolean {
return e.title.toLowerCase().includes(lowerQuery) ||
e.aliases.some(a => a.toLowerCase().includes(lowerQuery))
}
function toWikilinkMatch(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
const isA = e.isA
const te = typeEntryMap[isA ?? '']
const noteType = isA || undefined
const stem = e.filename.replace(/\.md$/, '')
return {
title: e.title,
stem,
noteType,
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined,
TypeIcon: noteType ? getTypeIcon(isA, te?.icon) : undefined,
}
}
function matchWikilinkEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>, query: string) {
if (query.length < MIN_WIKILINK_QUERY) return []
const lowerQuery = query.toLowerCase()
return entries
.filter(e => entryMatchesQuery(e, lowerQuery))
.slice(0, MAX_WIKILINK_RESULTS)
.map(e => toWikilinkMatch(e, typeEntryMap))
}
type WikilinkMatch = ReturnType<typeof toWikilinkMatch>
function extractWikilinkQuery(value: string): string | null {
return value.startsWith('[[') ? value.slice(2).replace(/]]$/, '') : null
}
function useOutsideClick(refs: React.RefObject<HTMLElement | null>[], onClose: () => void) {
useEffect(() => {
const handler = (e: MouseEvent) => {
const target = e.target as Node
if (refs.every(r => !r.current?.contains(target))) onClose()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [refs, onClose])
}
function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef, anchorRef }: {
matches: WikilinkMatch[]
selectedIndex: number
onSelect: (title: string, stem?: string) => void
onHover: (index: number) => void
menuRef: React.RefObject<HTMLDivElement | null>
anchorRef: React.RefObject<HTMLElement | null>
}) {
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null)
useEffect(() => {
const el = anchorRef.current
if (!el) return
const rect = el.getBoundingClientRect()
setPos({ top: rect.bottom + 2, left: rect.left, width: rect.width })
}, [anchorRef, matches])
if (!pos) return null
return createPortal(
<div
className="wikilink-menu wikilink-menu--filter"
ref={menuRef}
style={{ position: 'fixed', top: pos.top, left: pos.left, width: pos.width }}
data-testid="wikilink-dropdown"
>
{matches.map((item, index) => (
<div
key={item.title}
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={() => onSelect(item.title, item.stem)}
onMouseEnter={() => onHover(index)}
>
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{item.TypeIcon && <item.TypeIcon width={12} height={12} style={{ color: item.typeColor, flexShrink: 0 }} />}
{item.title}
</span>
{item.noteType && (
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 6px' }}>
{item.noteType}
</span>
)}
</div>
))}
</div>,
document.body,
)
}
function useWikilinkMatches(entries: VaultEntry[], value: string, open: boolean) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const wikilinkQuery = extractWikilinkQuery(value)
return useMemo(
() => (open && wikilinkQuery !== null) ? matchWikilinkEntries(entries, typeEntryMap, wikilinkQuery) : [],
[entries, typeEntryMap, wikilinkQuery, open],
)
}
function useScrollSelectedIntoView(menuRef: React.RefObject<HTMLDivElement | null>, selectedIndex: number) {
useEffect(() => {
if (selectedIndex < 0 || !menuRef.current) return
const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined
el?.scrollIntoView?.({ block: 'nearest' })
}, [selectedIndex, menuRef])
}
function useDropdownKeyboard(
matches: WikilinkMatch[],
open: boolean,
onSelect: (title: string, stem?: string) => void,
onClose: () => void,
) {
const [selectedIndex, setSelectedIndex] = useState(-1)
const resetIndex = useCallback(() => setSelectedIndex(-1), [])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!open || matches.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => (i + 1) % matches.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1))
} else if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault()
const m = matches[selectedIndex]
onSelect(m.title, m.stem)
} else if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
}, [open, matches, selectedIndex, onSelect, onClose])
return { selectedIndex, setSelectedIndex, resetIndex, handleKeyDown }
}
function WikilinkValueInput({ value, entries, onChange }: {
value: string
entries: VaultEntry[]
onChange: (v: string) => void
}) {
const [open, setOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const matches = useWikilinkMatches(entries, value, open)
const handleSelect = useCallback((title: string, stem?: string) => {
const wikilink = stem && stem !== title ? `[[${stem}|${title}]]` : `[[${title}]]`
onChange(wikilink)
setOpen(false)
}, [onChange])
const closeMenu = useCallback(() => setOpen(false), [])
useOutsideClick([inputRef, menuRef], closeMenu)
const { selectedIndex, setSelectedIndex, resetIndex, handleKeyDown } =
useDropdownKeyboard(matches, open, handleSelect, closeMenu)
useScrollSelectedIntoView(menuRef, selectedIndex)
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
setOpen(e.target.value.startsWith('[['))
resetIndex()
}, [onChange, resetIndex])
return (
<div className="flex-1 min-w-0">
<Input
ref={inputRef}
className="h-8 w-full text-sm"
placeholder="value"
value={value}
onChange={handleChange}
onFocus={() => { if (value.startsWith('[[')) setOpen(true) }}
onKeyDown={handleKeyDown}
data-testid="filter-value-input"
/>
{open && matches.length > 0 && (
<WikilinkDropdown
matches={matches}
selectedIndex={selectedIndex}
onSelect={handleSelect}
onHover={setSelectedIndex}
menuRef={menuRef}
anchorRef={inputRef}
/>
)}
</div>
)
}
function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const parsed = value ? parseISO(value) : undefined
const selected = parsed && !isNaN(parsed.getTime()) ? parsed : undefined
@@ -330,61 +121,27 @@ function DateValueInput({ value, onChange }: { value: string; onChange: (v: stri
)
}
function ValueInput({ value, suggestions, isDateOp, entries, onChange }: {
function TextValueInput({ value, onChange }: {
value: string
suggestions: string[]
isDateOp: boolean
entries: VaultEntry[]
onChange: (v: string) => void
}) {
if (isDateOp) {
return <DateValueInput value={value} onChange={onChange} />
}
if (suggestions.length > 0) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
size="sm"
className="h-8 flex-1 min-w-0 gap-1 border-input bg-background px-2 text-sm shadow-none"
>
<SelectValue placeholder="value" />
</SelectTrigger>
<SelectContent position="popper">
{value !== '' && !suggestions.includes(value) && (
<SelectItem value={value}>{value}</SelectItem>
)}
{suggestions.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
)
}
if (entries.length > 0) {
return <WikilinkValueInput value={value} entries={entries} onChange={onChange} />
}
return (
<Input
className="h-8 flex-1 min-w-0 text-sm"
placeholder="value"
value={value}
onChange={(e) => onChange(e.target.value)}
data-testid="filter-value-input"
/>
)
}
function FilterRow({ condition, fields, entries, valueSuggestions, onUpdate, onRemove }: {
function FilterRow({ condition, fields, onUpdate, onRemove }: {
condition: FilterCondition
fields: string[]
entries: VaultEntry[]
valueSuggestions: (field: string) => string[]
onUpdate: (c: FilterCondition) => void
onRemove: () => void
}) {
const suggestions = valueSuggestions(condition.field)
const isDateOp = DATE_OPS.has(condition.op)
return (
<div className="flex items-center gap-1.5">
@@ -398,13 +155,9 @@ function FilterRow({ condition, fields, entries, valueSuggestions, onUpdate, onR
onChange={(op) => onUpdate({ ...condition, op })}
/>
{!NO_VALUE_OPS.has(condition.op) && (
<ValueInput
value={String(condition.value ?? '')}
suggestions={suggestions}
isDateOp={isDateOp}
entries={entries}
onChange={(v) => onUpdate({ ...condition, value: v })}
/>
isDateOp
? <DateValueInput value={String(condition.value ?? '')} onChange={(v) => onUpdate({ ...condition, value: v })} />
: <TextValueInput value={String(condition.value ?? '')} onChange={(v) => onUpdate({ ...condition, value: v })} />
)}
<Button
type="button"
@@ -420,11 +173,9 @@ function FilterRow({ condition, fields, entries, valueSuggestions, onUpdate, onR
)
}
function FilterGroupView({ group, fields, entries, valueSuggestions, depth, onChange, onRemove }: {
function FilterGroupView({ group, fields, depth, onChange, onRemove }: {
group: FilterGroup
fields: string[]
entries: VaultEntry[]
valueSuggestions: (field: string) => string[]
depth: number
onChange: (g: FilterGroup) => void
onRemove?: () => void
@@ -492,8 +243,6 @@ function FilterGroupView({ group, fields, entries, valueSuggestions, depth, onCh
key={i}
group={child}
fields={fields}
entries={entries}
valueSuggestions={valueSuggestions}
depth={depth + 1}
onChange={(g) => updateChild(i, g)}
onRemove={() => removeChild(i)}
@@ -503,8 +252,6 @@ function FilterGroupView({ group, fields, entries, valueSuggestions, depth, onCh
key={i}
condition={child}
fields={fields}
entries={entries}
valueSuggestions={valueSuggestions}
onUpdate={(c) => updateChild(i, c)}
onRemove={() => removeChild(i)}
/>
@@ -527,22 +274,14 @@ export interface FilterBuilderProps {
group: FilterGroup
onChange: (group: FilterGroup) => void
availableFields: string[]
/** Returns known values for a given field (for autocomplete). */
valueSuggestions?: (field: string) => string[]
/** Vault entries for wikilink autocomplete in value fields. */
entries?: VaultEntry[]
}
const defaultSuggestions = () => [] as string[]
export function FilterBuilder({ group, onChange, availableFields, valueSuggestions, entries }: FilterBuilderProps) {
export function FilterBuilder({ group, onChange, availableFields }: FilterBuilderProps) {
const fields = availableFields.length > 0 ? availableFields : ['type']
return (
<FilterGroupView
group={group}
fields={fields}
entries={entries ?? []}
valueSuggestions={valueSuggestions ?? defaultSuggestions}
depth={0}
onChange={onChange}
/>

View File

@@ -162,6 +162,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
}) {
const isBinary = entry.fileKind === 'binary'
const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown'
const isDeletedChange = changeStatus === 'deleted'
const te = typeEntryMap[entry.isA ?? '']
const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
@@ -189,13 +190,22 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
onMouseEnter={!isBinary && onPrefetch ? () => onPrefetch(entry.path) : undefined}
data-testid={isMultiSelected ? 'multi-selected-item' : isBinary ? 'binary-file-item' : undefined}
data-highlighted={isHighlighted || undefined}
data-note-path={entry.path}
data-change-status={changeStatus}
title={isBinary ? 'Cannot open this file type' : undefined}
>
{changeStatus ? (
<>
<ChangeStatusIcon status={changeStatus} />
<div className="pr-5">
<div className={cn("truncate text-[13px] font-mono", isSelected ? "font-semibold" : "font-normal")} style={{ fontSize: 12 }}>
<div
className={cn(
"truncate text-[13px] font-mono",
isSelected ? "font-semibold" : "font-normal",
isDeletedChange && "text-muted-foreground line-through opacity-70",
)}
style={{ fontSize: 12 }}
>
{entry.filename}
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NoteList } from './NoteList'
import { NoteItem } from './NoteItem'
@@ -924,7 +924,7 @@ describe('NoteList — virtual list with large datasets', () => {
expect(icons).toHaveLength(2)
})
it('shows deleted notes banner when files are deleted', () => {
it('shows deleted notes as individual rows when files are deleted', () => {
const filesWithDeleted = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
@@ -934,17 +934,20 @@ describe('NoteList — virtual list with large datasets', () => {
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('26q1-laputa-app.md')).toBeInTheDocument()
expect(screen.getByText('2 notes deleted')).toBeInTheDocument()
expect(screen.getByText('gone.md')).toBeInTheDocument()
expect(screen.getByText('also-gone.md')).toBeInTheDocument()
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
})
it('shows singular form for single deleted note', () => {
it('renders deleted rows with dimmed strikethrough styling', () => {
const filesWithOneDeleted = [
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
]
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithOneDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
expect(screen.getByText('1 note deleted')).toBeInTheDocument()
expect(screen.getByText('gone.md')).toHaveClass('line-through')
expect(screen.getByText('gone.md')).toHaveClass('opacity-70')
})
it('does not show deleted banner when no files are deleted', () => {
@@ -975,6 +978,20 @@ describe('NoteList — virtual list with large datasets', () => {
expect(screen.getByTestId('discard-changes-button')).toBeInTheDocument()
})
it('shows "Restore note" on deleted rows in the changes context menu', () => {
const onDiscard = vi.fn()
const filesWithDeleted = [
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
]
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const noteItem = screen.getByText('gone.md').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
expect(screen.getByTestId('restore-note-button')).toBeInTheDocument()
})
it('does not show context menu when onDiscardFile is not provided', () => {
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
@@ -998,15 +1015,41 @@ describe('NoteList — virtual list with large datasets', () => {
expect(dialog.textContent).toContain('Build Laputa App')
})
it('opens the restore context menu action from Shift+F10 on a highlighted deleted row', () => {
const onDiscard = vi.fn()
const filesWithDeleted = [
{ path: '/Users/luca/Laputa/note/gone.md', relativePath: 'note/gone.md', status: 'deleted' as const },
]
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={filesWithDeleted} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const container = screen.getByTestId('note-list-container')
act(() => {
fireEvent.focus(container)
})
act(() => {
fireEvent.keyDown(container, { key: 'F10', shiftKey: true })
})
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
expect(screen.getByTestId('restore-note-button')).toBeInTheDocument()
})
it('calls onDiscardFile with relativePath when discard is confirmed', async () => {
const onDiscard = vi.fn().mockResolvedValue(undefined)
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const noteItem = screen.getByText('26q1-laputa-app.md').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
fireEvent.click(screen.getByTestId('discard-changes-button'))
fireEvent.click(screen.getByTestId('discard-confirm-button'))
act(() => {
fireEvent.contextMenu(noteItem)
})
act(() => {
fireEvent.click(screen.getByTestId('discard-changes-button'))
})
await act(async () => {
fireEvent.click(screen.getByTestId('discard-confirm-button'))
await Promise.resolve()
})
expect(onDiscard).toHaveBeenCalledWith('project/26q1-laputa-app.md')
})

View File

@@ -10,8 +10,7 @@ import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
import { NoteListHeader } from './note-list/NoteListHeader'
import { FilterPills } from './note-list/FilterPills'
import { EntityView, ListView } from './note-list/NoteListViews'
import { DeletedNotesBanner } from './note-list/TrashWarningBanner'
import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
import { type DeletedNoteEntry, isDeletedNoteEntry, routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
import {
useTypeEntryMap, useNoteListData, useNoteListSearch,
useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState,
@@ -47,15 +46,29 @@ function useBulkActions(
function ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }: { isChangesView: boolean; onDiscardFile?: (relativePath: string) => Promise<void>; modifiedFiles?: ModifiedFile[] }) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; entry: VaultEntry } | null>(null)
const [discardTarget, setDiscardTarget] = useState<VaultEntry | null>(null)
const [actionTarget, setActionTarget] = useState<{ entry: VaultEntry; action: 'discard' | 'restore'; relativePath: string } | null>(null)
const ctxMenuRef = useRef<HTMLDivElement>(null)
const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
const resolveActionTarget = useCallback((entry: VaultEntry) => {
const file = modifiedFiles?.find((modified) => modified.path === entry.path || entry.path.endsWith('/' + modified.relativePath))
if (!file) return null
return {
entry,
action: file.status === 'deleted' ? 'restore' as const : 'discard' as const,
relativePath: file.relativePath,
}
}, [modifiedFiles])
const openContextMenuForEntry = useCallback((entry: VaultEntry, point: { x: number; y: number }) => {
if (!isChangesView || !onDiscardFile) return
setCtxMenu({ x: point.x, y: point.y, entry })
}, [isChangesView, onDiscardFile])
const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setCtxMenu({ x: e.clientX, y: e.clientY, entry })
}, [isChangesView, onDiscardFile])
openContextMenuForEntry(entry, { x: e.clientX, y: e.clientY })
}, [openContextMenuForEntry])
const closeCtxMenu = useCallback(() => setCtxMenu(null), [])
@@ -68,44 +81,54 @@ function ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles }: { i
return () => document.removeEventListener('mousedown', handler)
}, [ctxMenu, closeCtxMenu])
const handleDiscardConfirm = useCallback(async () => {
if (!discardTarget || !onDiscardFile) return
const mf = modifiedFiles?.find((f) => f.path === discardTarget.path)
if (!mf) return
await onDiscardFile(mf.relativePath)
setDiscardTarget(null)
}, [discardTarget, onDiscardFile, modifiedFiles])
const handleChangeConfirm = useCallback(async () => {
if (!actionTarget || !onDiscardFile) return
await onDiscardFile(actionTarget.relativePath)
setActionTarget(null)
}, [actionTarget, onDiscardFile])
const menuActionTarget = ctxMenu ? resolveActionTarget(ctxMenu.entry) : null
const menuActionLabel = menuActionTarget?.action === 'restore' ? 'Restore note' : 'Discard changes'
const contextMenuNode = ctxMenu ? (
<div ref={ctxMenuRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: ctxMenu.x, top: ctxMenu.y, minWidth: 180 }} data-testid="changes-context-menu">
<button
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left text-destructive"
onClick={() => { setDiscardTarget(ctxMenu.entry); closeCtxMenu() }}
data-testid="discard-changes-button"
onClick={() => {
if (!menuActionTarget) return
setActionTarget(menuActionTarget)
closeCtxMenu()
}}
data-testid={menuActionTarget?.action === 'restore' ? 'restore-note-button' : 'discard-changes-button'}
>
Discard changes
{menuActionLabel}
</button>
</div>
) : null
const dialogNode = (
<Dialog open={!!discardTarget} onOpenChange={(open) => { if (!open) setDiscardTarget(null) }}>
<DialogContent showCloseButton={false} data-testid="discard-confirm-dialog">
<Dialog open={!!actionTarget} onOpenChange={(open) => { if (!open) setActionTarget(null) }}>
<DialogContent showCloseButton={false} data-testid={actionTarget?.action === 'restore' ? 'restore-confirm-dialog' : 'discard-confirm-dialog'}>
<DialogHeader>
<DialogTitle>Discard changes</DialogTitle>
<DialogTitle>{actionTarget?.action === 'restore' ? 'Restore note' : 'Discard changes'}</DialogTitle>
<DialogDescription>
Discard changes to <strong>{discardTarget?.title ?? 'this file'}</strong>? This cannot be undone.
{actionTarget?.action === 'restore'
? <>Restore <strong>{actionTarget?.entry.filename ?? 'this file'}</strong> from Git?</>
: <>Discard changes to <strong>{actionTarget?.entry.title ?? 'this file'}</strong>? This cannot be undone.</>
}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardTarget(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDiscardConfirm} data-testid="discard-confirm-button">Discard</Button>
<Button variant="outline" onClick={() => setActionTarget(null)}>Cancel</Button>
<Button variant={actionTarget?.action === 'restore' ? 'default' : 'destructive'} onClick={handleChangeConfirm} data-testid={actionTarget?.action === 'restore' ? 'restore-confirm-button' : 'discard-confirm-button'}>
{actionTarget?.action === 'restore' ? 'Restore' : 'Discard'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
return { handleNoteContextMenu, contextMenuNode, dialogNode }
return { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode }
}
interface NoteListProps {
@@ -130,11 +153,12 @@ interface NoteListProps {
onOpenInNewWindow?: (entry: VaultEntry) => void
onDiscardFile?: (relativePath: string) => Promise<void>
onAutoTriggerDiff?: () => void
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
views?: ViewFile[]
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
}
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkDeletePermanently, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, views, visibleNotesRef }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkDeletePermanently, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, onAutoTriggerDiff, onOpenDeletedNote, views, visibleNotesRef }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const { isSectionGroup, isFolderView, isInboxView, isAllNotesView, isChangesView, showFilterPills } = useViewFlags(selection)
@@ -160,35 +184,49 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
}
return map
}, [isChangesView, modifiedFiles])
const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
const { isEntityView, isArchivedView, searched, searchedGroups } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
// Keep the visible notes ref in sync for keyboard navigation (Cmd+Option+Arrow)
if (visibleNotesRef) {
visibleNotesRef.current = isEntityView
? searchedGroups.flatMap((g) => g.entries)
: searched
? searchedGroups.flatMap((g) => g.entries).filter((entry) => !isDeletedNoteEntry(entry))
: searched.filter((entry) => !isDeletedNoteEntry(entry))
}
const deletedCount = useMemo(
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
[isChangesView, modifiedFiles],
)
const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView })
const handleKeyboardOpen = useCallback((entry: VaultEntry) => {
if (isDeletedNoteEntry(entry)) {
onOpenDeletedNote?.(entry)
return
}
onReplaceActiveTab(entry)
}, [onOpenDeletedNote, onReplaceActiveTab])
const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => {
if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path)
}, [])
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: handleKeyboardOpen, onPrefetch: handleKeyboardPrefetch, enabled: !isEntityView })
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
useEffect(() => { multiSelect.clear() }, [selection, noteListFilter]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection/filter change
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
if (isDeletedNoteEntry(entry)) {
routeNoteClick(entry, e, {
onReplace: () => onOpenDeletedNote?.(entry),
onSelect: () => onOpenDeletedNote?.(entry),
multiSelect,
})
return
}
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, onOpenInNewWindow, multiSelect })
if (isChangesView && onAutoTriggerDiff) {
// Small delay to let the tab open before triggering diff
setTimeout(onAutoTriggerDiff, 50)
}
}, [onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff])
}, [onOpenDeletedNote, onReplaceActiveTab, onSelectNote, onOpenInNewWindow, multiSelect, isChangesView, onAutoTriggerDiff])
const { handleBulkArchive, handleBulkDeletePermanently, handleBulkUnarchive, bulkArchiveOrUnarchive } = useBulkActions(multiSelect, onBulkArchive, onBulkDeletePermanently, isArchivedView)
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrUnarchive, handleBulkDeletePermanently)
const { handleNoteContextMenu, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
const { handleNoteContextMenu, openContextMenuForEntry, contextMenuNode, dialogNode } = ChangesContextMenu({ isChangesView, onDiscardFile, modifiedFiles })
const getChangeStatus = useCallback((path: string) => {
if (!changeStatusMap) return undefined
@@ -201,8 +239,25 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
return undefined
}, [changeStatusMap])
const handleListKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (isChangesView && onDiscardFile && e.shiftKey && e.key === 'F10' && noteListKeyboard.highlightedPath) {
const entry = searched.find((candidate) => candidate.path === noteListKeyboard.highlightedPath)
if (!entry) return
e.preventDefault()
e.stopPropagation()
const row = document.querySelector<HTMLElement>(`[data-note-path="${entry.path}"]`)
const rect = row?.getBoundingClientRect()
openContextMenuForEntry(entry, {
x: rect ? rect.left + 24 : 160,
y: rect ? rect.bottom - 8 : 160,
})
return
}
noteListKeyboard.handleKeyDown(e)
}, [isChangesView, onDiscardFile, noteListKeyboard, searched, openContextMenuForEntry])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} changeStatus={getChangeStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, getChangeStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu])
const handleCreateNote = useCallback(() => {
@@ -214,15 +269,14 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} isSectionGroup={isSectionGroup} entries={entries} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onUpdateTypeProperty={onUpdateTypeSort} />
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={handleListKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{entitySelection ? (
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} deletedCount={deletedCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
<ListView isArchivedView={isArchivedView} isChangesView={isChangesView} isInboxView={isInboxView} changesError={modifiedFilesError} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} position="bottom" />}
</div>
{multiSelect.isMultiSelecting && (

View File

@@ -33,16 +33,15 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
)
}
export function ListView({ isArchivedView, isChangesView, isInboxView, changesError, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
export function ListView({ isArchivedView, isChangesView, isInboxView, changesError, searched, query, renderItem, virtuosoRef }: {
isArchivedView?: boolean; isChangesView?: boolean; isInboxView?: boolean; changesError?: string | null
deletedCount?: number; searched: VaultEntry[]; query: string
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, !!isArchivedView, !!isInboxView, query)
const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0
if (searched.length === 0 && !hasDeletedOnly) {
if (searched.length === 0) {
return (
<div className="h-full overflow-y-auto">
<EmptyMessage text={emptyText} />
@@ -50,10 +49,6 @@ export function ListView({ isArchivedView, isChangesView, isInboxView, changesEr
)
}
if (hasDeletedOnly) {
return <div className="h-full" />
}
return (
<Virtuoso
ref={virtuosoRef}

View File

@@ -9,7 +9,7 @@ import {
} from '../../utils/noteListHelpers'
import type { InboxPeriod } from '../../types'
import { buildTypeEntryMap } from '../../utils/typeColors'
import { filterByQuery, filterGroupsByQuery, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
import { buildChangesEntries, filterByQuery, filterGroupsByQuery, createNoteStatusResolver, isModifiedEntry } from './noteListUtils'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
// --- useTypeEntryMap ---
@@ -20,16 +20,19 @@ export function useTypeEntryMap(entries: VaultEntry[]) {
// --- useFilteredEntries ---
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod, views?: ViewFile[]) {
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], modifiedFiles?: ModifiedFile[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod, views?: ViewFile[]) {
const isEntityView = selection.kind === 'entity'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
return useMemo(() => {
if (isEntityView) return []
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
if (isChangesView) {
if (modifiedFiles) return buildChangesEntries(entries, modifiedFiles)
return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
}
if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month')
return filterEntries(entries, selection, subFilter, views)
}, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views])
}, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod, views])
}
// --- useNoteListData ---
@@ -38,16 +41,17 @@ interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection
query: string; listSort: SortOption; listDirection: SortDirection
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
modifiedFiles?: ModifiedFile[]
subFilter?: NoteListFilter
inboxPeriod?: InboxPeriod
views?: ViewFile[]
}
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views }: NoteListDataParams) {
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod, views }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views)
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod, views)
const searched = useMemo(() => {
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
@@ -160,7 +164,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
}
}, [typeDocument, persistence])
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod)
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, undefined, subFilter, inboxPeriod)
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'

View File

@@ -1,6 +1,11 @@
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
import type { RelationshipGroup } from '../../utils/noteListHelpers'
export interface DeletedNoteEntry extends VaultEntry {
__deletedNotePreview: true
__deletedRelativePath: string
}
export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null, views?: ViewFile[]): string {
if (selection.kind === 'view') {
const view = views?.find((v) => v.filename === selection.filename)
@@ -60,3 +65,84 @@ export function isModifiedEntry(path: string, pathSet: Set<string>, suffixes: st
if (pathSet.has(path)) return true
return suffixes.some((suffix) => path.endsWith(suffix))
}
export function isDeletedNoteEntry(entry: VaultEntry): entry is DeletedNoteEntry {
return '__deletedNotePreview' in entry && entry.__deletedNotePreview === true
}
function matchesModifiedFile(entry: VaultEntry, file: ModifiedFile): boolean {
return entry.path === file.path || entry.path.endsWith('/' + file.relativePath)
}
function createDeletedNoteEntry(file: ModifiedFile): DeletedNoteEntry {
const filename = file.relativePath.split('/').pop() ?? file.relativePath
return {
path: file.path,
filename,
title: filename.replace(/\.md$/, ''),
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: null,
createdAt: null,
fileSize: 0,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
__deletedNotePreview: true,
__deletedRelativePath: file.relativePath,
}
}
export function buildChangesEntries(entries: VaultEntry[], modifiedFiles: ModifiedFile[] | undefined): VaultEntry[] {
if (!modifiedFiles || modifiedFiles.length === 0) return []
const liveEntries = entries.filter((entry) =>
modifiedFiles.some((file) => file.status !== 'deleted' && matchesModifiedFile(entry, file)),
)
const deletedEntries = modifiedFiles
.filter((file) => file.status === 'deleted')
.filter((file) => !entries.some((entry) => matchesModifiedFile(entry, file)))
.map(createDeletedNoteEntry)
return [...liveEntries, ...deletedEntries]
}
export function extractDeletedContentFromDiff(diff: string): string | null {
const lines: string[] = []
let inHunk = false
for (const line of diff.split('\n')) {
if (line.startsWith('@@')) {
inHunk = true
continue
}
if (!inHunk) continue
if (line.startsWith('\\')) continue
if (line.startsWith('-') || line.startsWith(' ')) {
lines.push(line.slice(1))
}
}
return lines.length > 0 ? lines.join('\n') : null
}

View File

@@ -19,6 +19,8 @@ interface NoteCommandsConfig {
isFavorite?: boolean
onToggleOrganized?: (path: string) => void
isOrganized?: boolean
onRestoreDeletedNote?: () => void
canRestoreDeletedNote?: boolean
}
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
@@ -29,6 +31,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized,
onRestoreDeletedNote, canRestoreDeletedNote,
} = config
return [
@@ -46,6 +49,12 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
keywords: ['archive'], enabled: hasActiveNote,
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
},
{
id: 'restore-deleted-note', label: 'Restore Deleted Note', group: 'Note',
keywords: ['restore', 'deleted', 'undelete', 'git', 'checkout'],
enabled: !!canRestoreDeletedNote && !!onRestoreDeletedNote,
execute: () => onRestoreDeletedNote?.(),
},
{
id: 'toggle-favorite', label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites', group: 'Note', shortcut: '⌘D',
keywords: ['favorite', 'star', 'bookmark', 'pin'],

View File

@@ -67,6 +67,8 @@ interface AppCommandsConfig {
onOpenInNewWindow?: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRestoreDeletedNote?: () => void
canRestoreDeletedNote?: boolean
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -149,9 +151,11 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
onOpenInNewWindow: config.onOpenInNewWindow,
onRestoreDeletedNote: config.onRestoreDeletedNote,
activeTabPathRef: config.activeTabPathRef,
activeTabPath: config.activeTabPath,
modifiedCount: config.modifiedCount,
hasRestorableDeletedNote: config.canRestoreDeletedNote,
})
const commands = useCommandRegistry({
@@ -205,6 +209,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onOpenInNewWindow: config.onOpenInNewWindow,
onToggleFavorite: config.onToggleFavorite,
onToggleOrganized: config.onToggleOrganized,
onRestoreDeletedNote: config.onRestoreDeletedNote,
canRestoreDeletedNote: config.canRestoreDeletedNote,
})
useKeyboardNavigation({

View File

@@ -150,6 +150,21 @@ describe('useCommandRegistry', () => {
findCommand(result.current, 'set-note-icon')!.execute()
expect(onSetNoteIcon).toHaveBeenCalled()
})
it('includes restore deleted note command when provided', () => {
const config = makeConfig({ onRestoreDeletedNote: vi.fn(), canRestoreDeletedNote: true })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'restore-deleted-note')
expect(cmd).toBeDefined()
expect(cmd!.enabled).toBe(true)
})
it('disables restore deleted note when there is no deleted preview', () => {
const config = makeConfig({ onRestoreDeletedNote: vi.fn(), canRestoreDeletedNote: false })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'restore-deleted-note')
expect(cmd!.enabled).toBe(false)
})
})
describe('pluralizeType', () => {

View File

@@ -30,6 +30,8 @@ interface CommandRegistryConfig {
onOpenInNewWindow?: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRestoreDeletedNote?: () => void
canRestoreDeletedNote?: boolean
onQuickOpen: () => void
onCreateNote: () => void
onCreateNoteOfType: (type: string) => void
@@ -85,6 +87,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onReloadVault, onRepairVault,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
onOpenInNewWindow, onToggleFavorite, onToggleOrganized,
onRestoreDeletedNote, canRestoreDeletedNote,
selection, noteListFilter, onSetNoteListFilter,
} = config
@@ -108,6 +111,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onDeleteNote, onArchiveNote, onUnarchiveNote,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
onRestoreDeletedNote, canRestoreDeletedNote,
}),
...buildGitCommands({ modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect }),
...buildViewCommands({
@@ -137,6 +141,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
isSectionGroup, noteListFilter, onSetNoteListFilter,
onOpenInNewWindow, onToggleFavorite, isFavorite,
onToggleOrganized, activeEntry,
onToggleOrganized, onRestoreDeletedNote, canRestoreDeletedNote, activeEntry,
])
}

View File

@@ -35,8 +35,10 @@ function makeHandlers(): MenuEventHandlers {
onInstallMcp: vi.fn(),
onReloadVault: vi.fn(),
onOpenInNewWindow: vi.fn(),
onRestoreDeletedNote: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
activeTabPath: '/vault/test.md',
hasRestorableDeletedNote: false,
}
}
@@ -199,6 +201,12 @@ describe('dispatchMenuEvent', () => {
expect(h.onToggleAIChat).toHaveBeenCalled()
})
it('note-restore-deleted triggers restore deleted note', () => {
const h = makeHandlers()
dispatchMenuEvent('note-restore-deleted', h)
expect(h.onRestoreDeletedNote).toHaveBeenCalled()
})
it('view-toggle-backlinks triggers toggle inspector', () => {
const h = makeHandlers()
dispatchMenuEvent('view-toggle-backlinks', h)

View File

@@ -37,10 +37,12 @@ export interface MenuEventHandlers {
onOpenInNewWindow?: () => void
onReloadVault?: () => void
onRepairVault?: () => void
onRestoreDeletedNote?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
activeTabPath: string | null
modifiedCount?: number
conflictCount?: number
hasRestorableDeletedNote?: boolean
}
const VIEW_MODE_MAP: Record<string, ViewMode> = {
@@ -78,7 +80,7 @@ type OptionalHandler =
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
| 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReloadVault' | 'onRepairVault'
| 'onOpenInNewWindow'
| 'onOpenInNewWindow' | 'onRestoreDeletedNote'
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'view-go-back': 'onGoBack',
@@ -99,6 +101,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'vault-reload': 'onReloadVault',
'vault-repair': 'onRepairVault',
'note-open-in-new-window': 'onOpenInNewWindow',
'note-restore-deleted': 'onRestoreDeletedNote',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
@@ -162,7 +165,8 @@ export function useMenuEvents(handlers: MenuEventHandlers) {
hasActiveNote: handlers.activeTabPath !== null,
hasModifiedFiles: handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined,
hasConflicts: handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined,
hasRestorableDeletedNote: handlers.hasRestorableDeletedNote,
})
}).catch(() => {})
}, [handlers.activeTabPath, handlers.modifiedCount, handlers.conflictCount])
}, [handlers.activeTabPath, handlers.modifiedCount, handlers.conflictCount, handlers.hasRestorableDeletedNote])
}

View File

@@ -1,17 +1,17 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import type { VirtuosoHandle } from 'react-virtuoso'
import type { VaultEntry } from '../types'
import { prefetchNoteContent } from './useTabManagement'
interface NoteListKeyboardOptions {
items: VaultEntry[]
selectedNotePath: string | null
onOpen: (entry: VaultEntry) => void
onPrefetch?: (entry: VaultEntry) => void
enabled: boolean
}
export function useNoteListKeyboard({
items, selectedNotePath, onOpen, enabled,
items, selectedNotePath, onOpen, onPrefetch, enabled,
}: NoteListKeyboardOptions) {
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const virtuosoRef = useRef<VirtuosoHandle>(null)
@@ -30,7 +30,7 @@ export function useNoteListKeyboard({
setHighlightedIndex(prev => {
const next = Math.min((prev < 0 ? -1 : prev) + 1, items.length - 1)
virtuosoRef.current?.scrollToIndex({ index: next, behavior: 'auto' })
if (next >= 0 && next < items.length) prefetchNoteContent(items[next].path)
if (next >= 0 && next < items.length) onPrefetch?.(items[next])
return next
})
} else if (e.key === 'ArrowUp') {
@@ -38,14 +38,14 @@ export function useNoteListKeyboard({
setHighlightedIndex(prev => {
const next = Math.max((prev < 0 ? items.length : prev) - 1, 0)
virtuosoRef.current?.scrollToIndex({ index: next, behavior: 'auto' })
if (next >= 0 && next < items.length) prefetchNoteContent(items[next].path)
if (next >= 0 && next < items.length) onPrefetch?.(items[next])
return next
})
} else if (e.key === 'Enter' && highlightedIndex >= 0 && highlightedIndex < items.length) {
e.preventDefault()
onOpen(items[highlightedIndex])
}
}, [enabled, items, highlightedIndex, onOpen])
}, [enabled, items, highlightedIndex, onOpen, onPrefetch])
const handleFocus = useCallback(() => {
if (highlightedIndex >= 0 || items.length === 0) return

View File

@@ -490,8 +490,8 @@ describe('resolveNoteStatus', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [])).toBe('clean')
})
it('returns clean for deleted files', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('clean')
it('returns modified for deleted files so deleted previews keep diff affordances', () => {
expect(resolveNoteStatus('/vault/x.md', new Set(), [mf('/vault/x.md', 'deleted')])).toBe('modified')
})
it('newPaths takes priority over git modified', () => {

View File

@@ -83,7 +83,7 @@ export function resolveNoteStatus(
const gitEntry = modifiedFiles.find((f) => f.path === path)
if (!gitEntry) return 'clean'
if (gitEntry.status === 'untracked' || gitEntry.status === 'added') return 'new'
if (gitEntry.status === 'modified') return 'modified'
if (gitEntry.status === 'modified' || gitEntry.status === 'deleted') return 'modified'
return 'clean'
}

View File

@@ -35,6 +35,22 @@ function mockModifiedFiles(): ModifiedFile[] {
function mockFileDiff(path: string): string {
const filename = path.split('/').pop() ?? 'unknown'
if (filename === 'old-draft.md') {
return `diff --git a/${filename} b/${filename}
deleted file mode 100644
index abc1234..0000000
--- a/${filename}
+++ /dev/null
@@ -1,7 +0,0 @@
----
-title: Old Draft
-type: Note
----
-
-# Old Draft
-
-This note was deleted.`
}
return `diff --git a/${filename} b/${filename}
index abc1234..def5678 100644
--- a/${filename}

View File

@@ -73,11 +73,21 @@ async function openNote(page: import('@playwright/test').Page, title: string) {
await page.waitForTimeout(300)
}
/** Helper: rename the active note through the stable TitleField. */
async function renameActiveNote(page: import('@playwright/test').Page, nextTitle: string) {
const titleInput = page.getByTestId('title-field-input')
await expect(titleInput).toBeVisible({ timeout: 5_000 })
await titleInput.click()
await titleInput.fill(nextTitle)
await titleInput.press('Enter')
await expect(titleInput).toHaveValue(nextTitle, { timeout: 5_000 })
}
// ---------------------------------------------------------------------------
// 1. Vault loads entries from real fixture files
// ---------------------------------------------------------------------------
test('vault loads entries from fixture files', async ({ page }) => {
test('vault loads entries from fixture files @smoke', async ({ page }) => {
const list = noteList(page)
await expect(list.getByText('Alpha Project', { exact: true })).toBeVisible()
await expect(list.getByText('Note B', { exact: true })).toBeVisible()
@@ -120,24 +130,27 @@ test('trashed note does not appear in All Notes', async ({ page }) => {
// 4. Create note saves file to disk with correct slug
// ---------------------------------------------------------------------------
test('create note saves file to disk with correct slug', async ({ page }) => {
test('create note saves file to disk with correct slug @smoke', async ({ page }) => {
const beforeFiles = new Set(fs.readdirSync(tempVaultDir))
// "Create new note" instantly creates "Untitled note" and opens in editor
await page.locator('button[title="Create new note"]').click()
await page.waitForTimeout(500)
// Verify the new note opens — H1 heading should appear in the WYSIWYG editor
await expect(page.getByRole('heading', { name: /Untitled note/i, level: 1 })).toBeVisible({ timeout: 5_000 })
await expect(noteList(page).getByText(/Untitled Note \d+/).first()).toBeVisible({ timeout: 5_000 })
// Save to disk via Cmd+S
await page.keyboard.press('Meta+s')
// Poll for the file to appear on disk
const expectedPath = path.join(tempVaultDir, 'note', 'untitled-note.md')
// Poll for the new timestamp-based file to appear on disk.
await expect(async () => {
expect(fs.existsSync(expectedPath)).toBe(true)
const afterFiles = fs.readdirSync(tempVaultDir)
const createdFiles = afterFiles.filter(name => !beforeFiles.has(name) && /^untitled-note-\d+\.md$/.test(name))
expect(createdFiles).toHaveLength(1)
}).toPass({ timeout: 5_000 })
const content = fs.readFileSync(expectedPath, 'utf-8')
const createdFile = fs.readdirSync(tempVaultDir).find(name => !beforeFiles.has(name) && /^untitled-note-\d+\.md$/.test(name))
expect(createdFile).toBeTruthy()
const content = fs.readFileSync(path.join(tempVaultDir, createdFile!), 'utf-8')
expect(content).not.toContain('# Untitled note')
expect(content).toContain('type: Note')
})
@@ -146,22 +159,11 @@ test('create note saves file to disk with correct slug', async ({ page }) => {
// 5. Rename note updates filename on disk
// ---------------------------------------------------------------------------
test('rename note updates filename on disk', async ({ page }) => {
test('rename note updates filename on disk @smoke', async ({ page }) => {
// Open Note B
await openNote(page, 'Note B')
// Double-click the tab title — scope to the draggable tab element to avoid
// matching the breadcrumb span that also has class "truncate".
await page.locator('[draggable="true"] span.truncate', { hasText: 'Note B' }).dblclick()
await page.waitForTimeout(200)
// In rename mode, draggable becomes false and an <input> appears in the tab.
// It's the only <input> element on the page at this point.
const input = page.locator('input').first()
await expect(input).toBeVisible({ timeout: 2_000 })
await input.fill('Note B Renamed')
await input.press('Enter')
await page.waitForTimeout(500)
await renameActiveNote(page, 'Note B Renamed')
// Verify filesystem: old file gone, new file exists
const oldPath = path.join(tempVaultDir, 'note', 'note-b.md')
@@ -180,17 +182,10 @@ test('rename note updates filename on disk', async ({ page }) => {
// 6. Wikilink update on rename — other files' [[Note B]] updated
// ---------------------------------------------------------------------------
test('rename note updates wikilinks in other files', async ({ page }) => {
test('rename note updates wikilinks in other files @smoke', async ({ page }) => {
// Open Note B and rename it
await openNote(page, 'Note B')
await page.locator('[draggable="true"] span.truncate', { hasText: 'Note B' }).dblclick()
await page.waitForTimeout(200)
const input = page.locator('input').first()
await expect(input).toBeVisible({ timeout: 2_000 })
await input.fill('Note B Updated')
await input.press('Enter')
await renameActiveNote(page, 'Note B Updated')
// Wait for rename to complete (file to be moved)
const newPath = path.join(tempVaultDir, 'note', 'note-b-updated.md')

View File

@@ -13,12 +13,12 @@ test.describe('Command Palette smoke tests', () => {
await page.waitForLoadState('networkidle')
})
test('Cmd+K opens the command palette', async ({ page }) => {
test('Cmd+K opens the command palette @smoke', async ({ page }) => {
await openCommandPalette(page)
await verifyVisible(page, 'input[placeholder="Type a command..."]')
})
test('Escape closes the command palette', async ({ page }) => {
test('Escape closes the command palette @smoke', async ({ page }) => {
await openCommandPalette(page)
await closeCommandPalette(page)
await expect(
@@ -26,7 +26,7 @@ test.describe('Command Palette smoke tests', () => {
).not.toBeVisible()
})
test('typing filters the command list', async ({ page }) => {
test('typing filters the command list @smoke', async ({ page }) => {
await openCommandPalette(page)
const found = await findCommand(page, 'reload')
expect(found).toBe(true)
@@ -53,7 +53,7 @@ test.describe('Keyboard shortcuts smoke tests', () => {
await page.waitForLoadState('networkidle')
})
test('Cmd+P opens quick open palette', async ({ page }) => {
test('Cmd+P opens quick open palette @smoke', async ({ page }) => {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
await expect(

View File

@@ -12,64 +12,35 @@ async function openCreateViewDialog(page: Page) {
await expect(page.locator('text=Create View')).toBeVisible({ timeout: 5000 })
}
test.describe('Filter wikilink autocomplete', () => {
test.describe('Filter value input', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('typing [[ in filter value field shows wikilink autocomplete', async ({ page }) => {
test('default text filters use a plain text input instead of a dropdown', async ({ page }) => {
await openCreateViewDialog(page)
const dialog = page.getByRole('dialog')
// Default field is 'type' which has valueSuggestions (shows Select, not text input).
// Change to 'title' field which has no suggestions → renders WikilinkValueInput.
const fieldSelect = page.locator('button:has-text("type")').first()
await fieldSelect.click()
await page.locator('[role="option"]:has-text("title")').click()
// The filter value input has data-testid="filter-value-input"
const valueInput = page.getByTestId('filter-value-input')
const valueInput = dialog.getByTestId('filter-value-input')
await expect(valueInput).toBeVisible()
// Type [[ without enough chars - dropdown should not appear
await valueInput.fill('[[')
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
// Type enough characters to trigger the dropdown
await valueInput.fill('[[un')
await expect(page.getByTestId('wikilink-dropdown')).toBeVisible({ timeout: 2000 })
// Verify dropdown contains note suggestions
const dropdownItems = page.locator('.wikilink-menu__item')
const count = await dropdownItems.count()
expect(count).toBeGreaterThan(0)
// Click a suggestion to select it
const firstItem = dropdownItems.first()
const itemText = await firstItem.locator('.wikilink-menu__title').textContent()
await firstItem.click()
// Verify the value was set to [[note-title]]
const inputValue = await valueInput.inputValue()
expect(inputValue).toMatch(/^\[\[.+\]\]$/)
expect(inputValue).toContain(itemText?.trim() ?? '')
// Verify dropdown closed after selection
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
await valueInput.fill('Project')
await expect(valueInput).toHaveValue('Project')
await expect(dialog.getByTestId('wikilink-dropdown')).toHaveCount(0)
})
test('plain text in filter value does not trigger autocomplete', async ({ page }) => {
test('wikilink-like text stays raw input with no autocomplete', async ({ page }) => {
await openCreateViewDialog(page)
const dialog = page.getByRole('dialog')
// Change field to 'title' (no suggestions → WikilinkValueInput)
const fieldSelect = page.locator('button:has-text("type")').first()
const fieldSelect = dialog.locator('button:has-text("type")').first()
await fieldSelect.click()
await page.locator('[role="option"]:has-text("title")').click()
const valueInput = page.getByTestId('filter-value-input')
await valueInput.fill('some plain text')
const valueInput = dialog.getByTestId('filter-value-input')
await valueInput.fill('[[un')
// No dropdown should appear
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
await expect(valueInput).toHaveValue('[[un')
await expect(dialog.getByTestId('wikilink-dropdown')).toHaveCount(0)
})
})

View File

@@ -1,25 +1,23 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
test.describe('AI chat empty body fix — no regression', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
await page.goto('/')
await page.waitForTimeout(500)
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible({ timeout: 5_000 })
})
test('AI panel opens, note is selected, message can be sent and response renders', async ({ page }) => {
test('AI panel opens, note is selected, message can be sent and response renders @smoke', async ({ page }) => {
// Select a note so the AI panel has context
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(500)
// Verify editor has content (note body is loaded)
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 3000 })
// Open AI Chat with Ctrl+I
await sendShortcut(page, 'i', ['Control'])
// Open AI Chat from the editor toolbar
await page.getByTitle('Open AI Chat').click()
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
// Send a message

View File

@@ -10,14 +10,19 @@ async function openFirstNote(page: Page) {
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.waitFor({ timeout: 5000 })
await noteList.locator('.cursor-pointer').first().click()
await page.waitForTimeout(500)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 })
}
async function toggleRawMode(page: Page) {
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await page.waitForTimeout(500)
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function openBlockNoteMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
/** Get the full text content from the CodeMirror raw editor. */
@@ -53,87 +58,65 @@ test.describe('Raw editor ↔ BlockNote sync', () => {
await page.waitForLoadState('networkidle')
})
test('editing in raw mode and switching to BlockNote shows updated content', async ({ page }) => {
test('editing in raw mode and switching to BlockNote shows updated content @smoke', async ({ page }) => {
await openFirstNote(page)
// Read the original H1 from the BlockNote editor
const h1Locator = page.locator('.bn-editor h1.bn-inline-content').first()
await expect(h1Locator).toBeVisible({ timeout: 5000 })
const originalH1 = await h1Locator.textContent()
expect(originalH1).toBeTruthy()
// Toggle to raw mode
await toggleRawMode(page)
await expect(page.locator('.cm-content')).toBeVisible()
await openRawMode(page)
// Read raw content and verify it contains the original title
// Append unique text in raw mode, then verify it appears in BlockNote.
const rawContent = await getRawEditorContent(page)
expect(rawContent).toContain(originalH1!)
// Replace the H1 line with a new heading via CodeMirror dispatch
const newTitle = 'Updated By Raw Editor'
const updatedContent = rawContent.replace(
new RegExp(`# ${originalH1!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
`# ${newTitle}`,
)
expect(rawContent).toBeTruthy()
const appendedText = `Raw editor sync ${Date.now()}`
const updatedContent = `${rawContent}\n\n${appendedText}`
await setRawEditorContent(page, updatedContent)
await page.waitForTimeout(600) // Wait for debounce (500ms)
// Toggle back to BlockNote
await toggleRawMode(page)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(500)
await openBlockNoteMode(page)
// Verify the BlockNote editor shows the updated heading
const updatedH1 = page.locator('.bn-editor h1.bn-inline-content').first()
await expect(updatedH1).toContainText(newTitle, { timeout: 5000 })
// Verify the BlockNote editor shows the appended text.
await expect(page.locator('.bn-editor')).toContainText(appendedText, { timeout: 5000 })
})
test('switching BlockNote → raw → BlockNote multiple times preserves content', async ({ page }) => {
test('switching BlockNote → raw → BlockNote multiple times preserves content @smoke', async ({ page }) => {
await openFirstNote(page)
// Cycle 1: toggle to raw, edit, toggle back
await toggleRawMode(page)
await expect(page.locator('.cm-content')).toBeVisible()
await openRawMode(page)
const rawContent1 = await getRawEditorContent(page)
const edit1 = rawContent1.replace(/# .+/, '# Cycle One Title')
const cycleOneText = `Cycle one ${Date.now()}`
const edit1 = `${rawContent1}\n\n${cycleOneText}`
await setRawEditorContent(page, edit1)
await page.waitForTimeout(600)
await toggleRawMode(page)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(500)
await openBlockNoteMode(page)
await expect(
page.locator('.bn-editor h1.bn-inline-content').first(),
).toContainText('Cycle One Title', { timeout: 5000 })
await expect(page.locator('.bn-editor')).toContainText(cycleOneText, { timeout: 5000 })
// Cycle 2: toggle to raw again, verify content persisted, edit again
await toggleRawMode(page)
await expect(page.locator('.cm-content')).toBeVisible()
await openRawMode(page)
const rawContent2 = await getRawEditorContent(page)
expect(rawContent2).toContain('Cycle One Title')
expect(rawContent2).toContain(cycleOneText)
const edit2 = rawContent2.replace(/# .+/, '# Cycle Two Title')
const cycleTwoText = `Cycle two ${Date.now()}`
const edit2 = `${rawContent2}\n\n${cycleTwoText}`
await setRawEditorContent(page, edit2)
await page.waitForTimeout(600)
await toggleRawMode(page)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(500)
await openBlockNoteMode(page)
await expect(
page.locator('.bn-editor h1.bn-inline-content').first(),
).toContainText('Cycle Two Title', { timeout: 5000 })
await expect(page.locator('.bn-editor')).toContainText(cycleOneText, { timeout: 5000 })
await expect(page.locator('.bn-editor')).toContainText(cycleTwoText, { timeout: 5000 })
})
test('appended text in raw mode appears in BlockNote after switch', async ({ page }) => {
await openFirstNote(page)
// Toggle to raw and append text via CodeMirror dispatch
await toggleRawMode(page)
await openRawMode(page)
await expect(page.locator('.cm-content')).toBeVisible()
const content = await getRawEditorContent(page)
@@ -141,7 +124,7 @@ test.describe('Raw editor ↔ BlockNote sync', () => {
await page.waitForTimeout(600) // Wait for debounce
// Toggle back to BlockNote
await toggleRawMode(page)
await openBlockNoteMode(page)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 })
await page.waitForTimeout(500)

View File

@@ -13,25 +13,24 @@ test.describe('Show deleted notes in Changes view', () => {
await page.waitForLoadState('networkidle')
})
test('changes view shows deleted notes banner', async ({ page }) => {
test('changes view shows deleted notes as rows instead of a banner', async ({ page }) => {
await navigateToChanges(page)
const banner = page.locator('[data-testid="deleted-notes-banner"]')
await expect(banner).toBeVisible({ timeout: 5000 })
await expect(banner).toContainText('1 note deleted')
const deletedRow = page.locator('[data-change-status="deleted"]').filter({ hasText: 'old-draft.md' })
await expect(deletedRow).toBeVisible({ timeout: 5000 })
await expect(page.locator('[data-testid="deleted-notes-banner"]')).toHaveCount(0)
})
test('deleted banner is visually distinct from note items', async ({ page }) => {
test('clicking a deleted row opens its deleted diff preview', async ({ page }) => {
await navigateToChanges(page)
const banner = page.locator('[data-testid="deleted-notes-banner"]')
await expect(banner).toBeVisible({ timeout: 5000 })
// Banner should have reduced opacity (visually distinct)
const opacity = await banner.evaluate((el) => window.getComputedStyle(el).opacity)
expect(parseFloat(opacity)).toBeLessThan(1)
await page.getByText('old-draft.md').click()
await expect(page.getByText('Back to editor')).toBeVisible({ timeout: 5000 })
await expect(page.getByText('This note was deleted.')).toBeVisible({ timeout: 5000 })
})
test('changes counter in sidebar matches list items plus deleted count', async ({ page }) => {
// The sidebar badge should show the total count (modified + added + deleted)
const changesBadge = page.locator('text=Changes').locator('..')
await expect(changesBadge).toBeVisible({ timeout: 5000 })
test('deleted rows expose a restore action from the context menu', async ({ page }) => {
await navigateToChanges(page)
await page.getByText('old-draft.md').click({ button: 'right' })
await expect(page.locator('[data-testid="changes-context-menu"]')).toBeVisible({ timeout: 5000 })
await expect(page.locator('[data-testid="restore-note-button"]')).toBeVisible({ timeout: 5000 })
})
})