Compare commits
65 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abf6b51369 | ||
|
|
02c784b286 | ||
|
|
fc4ba24c4e | ||
|
|
eaeb6e5d40 | ||
|
|
64fe0f1c25 | ||
|
|
474353718a | ||
|
|
a933e6a787 | ||
|
|
79689839b2 | ||
|
|
b10e9facad | ||
|
|
2a32d2b5ad | ||
|
|
11a4e1593b | ||
|
|
080e5ff62d | ||
|
|
60fd4d9ade | ||
|
|
3583cb9518 | ||
|
|
1f497e4b18 | ||
|
|
13b325217b | ||
|
|
1714da402e | ||
|
|
7b75cb79c4 | ||
|
|
a66eedbecd | ||
|
|
dc92fd1f57 | ||
|
|
4012a65a73 | ||
|
|
b2da813923 | ||
|
|
e461a91721 | ||
|
|
edf24898ae | ||
|
|
1c8542bd01 | ||
|
|
aef98f17eb | ||
|
|
5c85bc41f6 | ||
|
|
60f3139b3e | ||
|
|
e40c09a2ef | ||
|
|
f90f703096 | ||
|
|
eaf31ff61c | ||
|
|
db50f779c9 | ||
|
|
e228bd3a52 | ||
|
|
3a0fd0620f | ||
|
|
e2489b8957 | ||
|
|
11f8731d32 | ||
|
|
6f7a7d71d8 | ||
|
|
aef1924407 | ||
|
|
2173df6f0d | ||
|
|
98cad76aa0 | ||
|
|
f076c71cc1 | ||
|
|
44221e50d4 | ||
|
|
b46c71c76f | ||
|
|
06c01539af | ||
|
|
fddc323d1e | ||
|
|
23b63bb583 | ||
|
|
c858cf8d3b | ||
|
|
0dc684453d | ||
|
|
aafe69b573 | ||
|
|
a3c53c19d1 | ||
|
|
5185f363e6 | ||
|
|
3915363026 | ||
|
|
2249c4a450 | ||
|
|
6575ec2d1c | ||
|
|
1f08694e9d | ||
|
|
a99cb2af78 | ||
|
|
8eabcd9467 | ||
|
|
97112b9c84 | ||
|
|
a53819e56a | ||
|
|
41a2d25311 | ||
|
|
3a3d0bbcdf | ||
|
|
a4468289a2 | ||
|
|
bcfd37d481 | ||
|
|
f27ebe05c4 | ||
|
|
7470e4f4a7 |
@@ -1 +0,0 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5V3BxWUNBZU1LZWxOK3ZEZTZkdGhzM2l6cnpVUmIvUEtTTWgzLzNEU1VoZ0FBQkFBQUFBQUFBQUFBQUlBQUFBQUFpN2xxclpGK3YzRERub1EvZFdsdVdORktuOHZYVlB0S2U2QkhNdlMreElkRVdabTh6UllNYzNFb2VWYTVCayszNUpyOC94Z0dJS2pVQ3NSKzdKNEszZHpDZll0aGdtV1J6bWFXODc4VWJpOVFYdUhEai9tNHQ2U3ZRbVd1NHBkR01YS2ZrUDlPRVU9Cg==
|
||||
@@ -1 +0,0 @@
|
||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVCQTVDRkIzNkFGRTYwQjUKUldTMVlQNXFzOCtsVzROZmJLR0JFVGw1a1UzKzViY3dUcWFoaUttRFhhVk8rVUhrc29QL1FPeXUK
|
||||
@@ -1 +0,0 @@
|
||||
91305
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -30,9 +30,6 @@ dist-ssr
|
||||
# Coverage reports
|
||||
/coverage/
|
||||
|
||||
# Laputa vault cache
|
||||
.laputa-cache.json
|
||||
|
||||
# Demo vault and helper scripts
|
||||
demo-vault/
|
||||
select_demo_notes*.py
|
||||
@@ -45,3 +42,28 @@ src-tauri/target
|
||||
|
||||
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
|
||||
src-tauri/resources/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Dev screenshots
|
||||
screenshots/
|
||||
|
||||
# Stale planning docs (keep locally if needed, not in repo)
|
||||
REDESIGN-PLAN.md
|
||||
SF-SYMBOLS-MIGRATION.md
|
||||
CODE-HEALTH-REPORT.md
|
||||
|
||||
# Local home dir artifact from worktree ops
|
||||
(HOME)/
|
||||
|
||||
# Runtime / process files
|
||||
.claude-pid
|
||||
|
||||
# Generated vault index files (qmd/search artifacts)
|
||||
.laputa-index.json
|
||||
|
||||
# Tauri signing keys (never commit private keys)
|
||||
*.key
|
||||
*.key.pub
|
||||
|
||||
46
CLAUDE.md
46
CLAUDE.md
@@ -36,15 +36,50 @@ BASE_URL="http://localhost:<N>" pnpm playwright:smoke
|
||||
kill $DEV_PID
|
||||
```
|
||||
|
||||
**What to test in Playwright:**
|
||||
- Every command palette entry from the spec → open `Cmd+K`, type the command name, verify it appears and executes
|
||||
**You must write a new Playwright test for this task** in `tests/smoke/<slug>.spec.ts` that covers every acceptance criterion. Do not rely only on existing smoke tests — they test the app in general, not your specific feature.
|
||||
|
||||
**What to cover in your Playwright test:**
|
||||
- Every acceptance criterion from the task spec → one `test()` block per criterion
|
||||
- Every command palette entry → open `Cmd+K`, type the command name, verify it appears and executes
|
||||
- Every keyboard shortcut → send keydown events, verify UI state changes
|
||||
- Every UI element described in the spec → verify it renders, is focusable, responds to Tab
|
||||
- Edge cases: empty state, long text, rapid keypresses
|
||||
- **The happy path end-to-end**: simulate exactly what a user would do to use this feature
|
||||
|
||||
**Playwright is non-negotiable even if tests pass.** Unit tests verify code; Playwright verifies the user experience in the real browser. Both are required.
|
||||
**The test must fail before your fix and pass after.** If you can't write a test that demonstrates the bug is fixed, your test doesn't cover the right thing.
|
||||
|
||||
> **⚠️ Browser dev server limits**: the dev server uses mock Tauri handlers (`src/mock-tauri.ts`) — file system operations, git commands, and native dialogs are mocked. Test those via `pnpm tauri dev` in Phase 2 if the task touches them.
|
||||
**Playwright is non-negotiable even if unit tests pass.** Unit tests verify code; Playwright verifies the user experience in the real browser. Both are required.
|
||||
|
||||
> **⚠️ Browser dev server limits**: the dev server uses mock Tauri handlers (`src/mock-tauri.ts`) — file system operations, git commands, and native dialogs are mocked. **If your task touches the filesystem, AI context pipeline, MCP server, git integration, or any Tauri command that reads/writes real files, Playwright alone is not enough.** You must also do Phase 1b.
|
||||
|
||||
### Phase 1b: Tauri dev QA (you do this for filesystem/native tasks)
|
||||
|
||||
If your task touches **any of the following**, you must also test with `pnpm tauri dev` against the real vault before firing done:
|
||||
- File read/write (notes, cache, vault config)
|
||||
- AI chat context (what the AI actually receives as input)
|
||||
- MCP server / subprocess communication
|
||||
- Git integration (commit, push, history, diff)
|
||||
- Native dialogs or OS-level features
|
||||
|
||||
```bash
|
||||
# Start Tauri dev app from your worktree
|
||||
pnpm tauri dev --port <N> &
|
||||
sleep 10 # wait for Tauri + Vite to boot
|
||||
|
||||
# Then test using osascript keyboard events (NO mouse/cliclick)
|
||||
# Example:
|
||||
osascript -e 'tell application "laputa" to activate'
|
||||
osascript -e 'tell application "System Events" to keystroke "k" using command down'
|
||||
# ...simulate the full user flow from the acceptance criteria
|
||||
```
|
||||
|
||||
**What to verify in Phase 1b:**
|
||||
- Open the feature on a real note in `~/Laputa` (not the demo vault)
|
||||
- Walk through every acceptance criterion step by step using keyboard only
|
||||
- Verify file changes with `git -C ~/Laputa diff` if the task writes files
|
||||
- Verify AI responses actually contain note content (not empty) if the task touches AI context
|
||||
|
||||
**⚠️ Claude Code runs headless — you cannot see the screen.** Use `screencapture /tmp/qa-check.png` and then read/describe what you see if you need visual verification. Or rely on DOM state checks via osascript accessibility API.
|
||||
|
||||
### Phase 2: Native Tauri QA (Brian does this after you push)
|
||||
|
||||
@@ -64,6 +99,9 @@ Brian installs the release build and runs keyboard-only QA on the native app. Yo
|
||||
- Every QA comment must include: the exact keyboard/command palette steps used, what was visible before and after, and any edge case tested.
|
||||
- If you cannot test a feature using keyboard only (osascript shortcuts + command palette), the feature is not keyboard-first → QA fails.
|
||||
|
||||
**⚠️ Phase 1 is YOUR quality gate, not a formality.**
|
||||
Brian's Phase 2 QA is a *reinforcement* check, not the primary gate. If Brian finds a bug in Phase 2 that you could have caught in Phase 1, that is a Phase 1 failure — not a Phase 2 discovery. Before firing the done signal, ask yourself: "Did I actually verify, in Playwright, that the feature works end-to-end exactly as the spec describes?" If the answer is "I ran the smoke tests and they passed", that is not enough. You must run your task-specific test and verify the acceptance criteria one by one.
|
||||
|
||||
**⚠️ Test in a clean environment when the feature depends on state.**
|
||||
If a feature involves indexing, fresh installs, first-time setup, or anything that only runs once:
|
||||
- **Do not test in the existing dev vault** — it already has the state you're trying to test.
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
# Code Health Report — Laputa App
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Branch:** `main`
|
||||
**Overall Project Score:** 9.33 / 10.0 (Green — up from 9.14)
|
||||
**Tool:** CodeScene Code Health Analysis (project ID: 76865)
|
||||
**Previous Report:** 2026-02-20 on `main` — 9.14 / 10.0
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Laputa App codebase scores **9.33** overall — a further improvement of **+0.19** from the previous report (9.14). The codebase remains solidly in **Green**, driven by the `vault.rs` refactoring (+2.59 to 8.81) and the `frontmatter.rs` refactoring (+2.79 to 9.68, now Green). Five files remain in the Yellow zone, down from six — `frontmatter.rs` has exited Yellow into Green.
|
||||
|
||||
| Zone | Score Range | File Count | Description |
|
||||
|------|------------|------------|-------------|
|
||||
| Optimal | 10.0 | 8 | Perfect — optimized for human and AI comprehension |
|
||||
| Green | 9.0 – 9.9 | 15 | High quality, minor issues only |
|
||||
| Yellow | 4.0 – 8.9 | 5 | Problematic technical debt |
|
||||
| Red | 1.0 – 3.9 | 0 | — |
|
||||
| N/A | — | 6 | CSS files (4) and tiny utility files (2) — unsupported by CodeScene |
|
||||
|
||||
---
|
||||
|
||||
## Refactoring Completed (vault.rs + frontmatter.rs)
|
||||
|
||||
The following refactorings were executed on vault.rs and frontmatter.rs, raising both files significantly:
|
||||
|
||||
### vault.rs: 6.22 → 8.81 (+2.59)
|
||||
|
||||
Refactored in 5 commits across multiple phases:
|
||||
|
||||
1. **Extracted `run_git` helper** — Consolidated duplicated git command execution into a single helper function, flattening git functions (`git_changed_files`, `git_uncommitted_new_files`).
|
||||
2. **Decomposed `parse_md_file`** — Extracted `parse_frontmatter_fields`, `extract_title`, `extract_snippet`, and `extract_relationships` into focused sub-functions. Flattened deep nesting with early returns.
|
||||
3. **Decomposed `scan_vault_cached`** — Extracted `process_vault_entry`, `collect_vault_entries`, `apply_git_status`, and `build_vault_response` as focused functions.
|
||||
4. **Split large test assertion blocks** — Broke monolithic assertion blocks into per-field assertions for readability and maintainability.
|
||||
5. **Converted internal functions to use `&Path`** instead of `&str` for vault/file paths, reducing string-heavy arguments.
|
||||
|
||||
All 8 original code smells (3 Bumpy Roads, 4 Deep Nestings, 2 Complex Methods, 2 Large Methods, String-Heavy Args, Large Assertion Blocks) have been resolved. The CodeScene review now reports **zero code smells**.
|
||||
|
||||
### frontmatter.rs: 6.89 → 9.68 (+2.79) — Yellow → Green
|
||||
|
||||
Refactored in 4 commits:
|
||||
|
||||
1. **Flattened `update_frontmatter_content`** — Used early returns and extracted `find_key_line_range` and `build_updated_content` helpers. Eliminated bumpy road (4 bumps) and deep nesting (4 levels).
|
||||
2. **Simplified `FrontmatterValue::to_yaml_value`** — Extracted `needs_yaml_quoting` predicate, simplified match arms. Reduced cc from 17.
|
||||
3. **Simplified `format_yaml_key`** — Extracted key-quoting rules into `key_needs_quoting` predicate. Reduced complex conditionals from 5.
|
||||
4. **Extracted line-parsing helpers** — `line_is_key` and related helpers for clean YAML line detection.
|
||||
|
||||
All original code smells (1 Bumpy Road, 1 Deep Nesting, 2 Complex Methods, 4 Complex Conditionals) have been resolved. Only one minor issue remains: **String Heavy Function Arguments** (73% of args are string types).
|
||||
|
||||
---
|
||||
|
||||
## Change Summary vs Previous Report (Feb 17)
|
||||
|
||||
| File | Previous | Current | Delta | Notes |
|
||||
|------|----------|---------|-------|-------|
|
||||
| `src/App.tsx` | 7.13 | **9.28** | **+2.15** | Yellow -> Green. Brain Method eliminated via hook extraction |
|
||||
| `src/components/Inspector.tsx` | 7.49 | **9.02** | **+1.53** | Yellow -> Green. Decomposed into sub-components |
|
||||
| `src-tauri/src/vault.rs` | 4.80 | **8.81** | **+4.01** | Still Yellow but near-Green. All code smells resolved |
|
||||
| `src-tauri/src/frontmatter.rs` | 6.89* | **9.68** | **+2.79** | Yellow -> Green. All major smells resolved |
|
||||
| `src/components/Editor.tsx` | 6.94 | **7.68** | **+0.74** | Still Yellow. DiffView/wikilinks extracted but Editor still too large |
|
||||
| `src/components/Sidebar.tsx` | 9.02 | **9.14** | +0.12 | Green (stable) |
|
||||
| `src/components/NoteList.tsx` | 8.11 | **8.05** | -0.06 | Yellow (stable, slight regression) |
|
||||
| `src/components/QuickOpenPalette.tsx` | 9.55 | **9.55** | = | Green (unchanged) |
|
||||
| `src-tauri/src/lib.rs` | 9.68 | **9.68** | = | Green (unchanged) |
|
||||
| `src-tauri/src/main.rs` | 10.0 | **10.0** | = | Optimal (unchanged) |
|
||||
| `src-tauri/src/git.rs` | 10.0 | **10.0** | = | Optimal (unchanged) |
|
||||
| `src/components/StatusBar.tsx` | 10.0 | **9.23** | -0.77 | Regression: Optimal -> Green (new features added) |
|
||||
| `src/mock-tauri.ts` | 10.0 | **9.37** | -0.63 | Regression: Optimal -> Green (new mock data added) |
|
||||
|
||||
*frontmatter.rs was extracted from vault.rs; "previous" is its initial score after extraction.
|
||||
|
||||
### New Files (not in previous report)
|
||||
|
||||
| File | Score | Zone | Notes |
|
||||
|------|-------|------|-------|
|
||||
| `src/hooks/useNoteActions.ts` | **7.81** | Yellow | Extracted from App.tsx — still needs decomposition |
|
||||
| `src/components/AIChatPanel.tsx` | **8.51** | Yellow | New feature — large component |
|
||||
| `src/components/DynamicPropertiesPanel.tsx` | **9.06** | Green | Extracted from Inspector.tsx |
|
||||
| `src/components/DiffView.tsx` | **9.09** | Green | Extracted from Editor.tsx |
|
||||
| `src/utils/frontmatter.ts` | **9.24** | Green | Extracted from Inspector.tsx |
|
||||
| `src/components/CommitDialog.tsx` | **9.38** | Green | New component |
|
||||
| `src/hooks/useVaultLoader.ts` | **9.41** | Green | Extracted from App.tsx |
|
||||
| `src/utils/wikilinks.ts` | **9.53** | Green | Extracted from Editor.tsx |
|
||||
| `src/hooks/useTheme.ts` | **9.68** | Green | New hook |
|
||||
| `src/components/EditableValue.tsx` | **10.0** | Optimal | Extracted from Inspector.tsx |
|
||||
| `src/components/ResizeHandle.tsx` | **10.0** | Optimal | New component |
|
||||
| `src/components/CreateNoteDialog.tsx` | **10.0** | Optimal | New component |
|
||||
| `src/components/Toast.tsx` | **10.0** | Optimal | New component |
|
||||
| `src/utils/typeColors.ts` | **10.0** | Optimal | New utility |
|
||||
| `src/main.tsx` | **10.0** | Optimal | Entry point |
|
||||
|
||||
---
|
||||
|
||||
## File-by-File Scores (All 34 Files)
|
||||
|
||||
| File | LoC | Score | Zone | Key Issues |
|
||||
|------|-----|-------|------|------------|
|
||||
| `src-tauri/src/main.rs` | 6 | **10.0** | Optimal | None |
|
||||
| `src-tauri/src/git.rs` | 423 | **10.0** | Optimal | None |
|
||||
| `src/components/EditableValue.tsx` | 167 | **10.0** | Optimal | None |
|
||||
| `src/components/ResizeHandle.tsx` | 74 | **10.0** | Optimal | None |
|
||||
| `src/components/CreateNoteDialog.tsx` | 99 | **10.0** | Optimal | None |
|
||||
| `src/components/Toast.tsx` | 28 | **10.0** | Optimal | None |
|
||||
| `src/utils/typeColors.ts` | 37 | **10.0** | Optimal | None |
|
||||
| `src/main.tsx` | 16 | **10.0** | Optimal | None |
|
||||
| `src-tauri/src/frontmatter.rs` | 279 | **9.68** | Green | String-heavy function arguments (73%) |
|
||||
| `src-tauri/src/lib.rs` | 80 | **9.68** | Green | String-heavy function arguments |
|
||||
| `src/hooks/useTheme.ts` | 51 | **9.68** | Green | None significant |
|
||||
| `src/components/QuickOpenPalette.tsx` | 145 | **9.55** | Green | Complex Method (cc=16) |
|
||||
| `src/utils/wikilinks.ts` | 68 | **9.53** | Green | None significant |
|
||||
| `src/hooks/useVaultLoader.ts` | 123 | **9.41** | Green | None significant |
|
||||
| `src/components/CommitDialog.tsx` | 73 | **9.38** | Green | None significant |
|
||||
| `src/mock-tauri.ts` | 894 | **9.37** | Green | None significant |
|
||||
| `src/App.tsx` | 176 | **9.28** | Green | Complex Method: App() cc=16 / 130 LoC |
|
||||
| `src/utils/frontmatter.ts` | 72 | **9.24** | Green | None significant |
|
||||
| `src/components/StatusBar.tsx` | 159 | **9.23** | Green | None significant |
|
||||
| `src/components/Sidebar.tsx` | 208 | **9.14** | Green | None significant |
|
||||
| `src/components/DiffView.tsx` | 45 | **9.09** | Green | None significant |
|
||||
| `src/components/DynamicPropertiesPanel.tsx` | 265 | **9.06** | Green | None significant |
|
||||
| `src/components/Inspector.tsx` | 312 | **9.02** | Green | None significant |
|
||||
| `src-tauri/src/vault.rs` | 1111 | **8.81** | Yellow | No code smells reported — near Green threshold |
|
||||
| `src/components/AIChatPanel.tsx` | 364 | **8.51** | Yellow | Complex Method: AIChatPanel() cc=15 / 285 LoC |
|
||||
| `src/components/NoteList.tsx` | 434 | **8.05** | Yellow | Complex Method: NoteListInner() cc=28 / 208 LoC |
|
||||
| `src/hooks/useNoteActions.ts` | 280 | **7.81** | Yellow | Bumpy Road, Deep Nesting, Complex Method: useNoteActions() cc=30 / 169 LoC |
|
||||
| `src/components/Editor.tsx` | 575 | **7.68** | Yellow | **Brain Method**: Editor() cc=61 / 385 LoC, Bumpy Road |
|
||||
| `src/types.ts` | 38 | N/A | — | Type definitions only |
|
||||
| `src/lib/utils.ts` | 6 | N/A | — | Utility (too small) |
|
||||
| `src/App.css` | — | N/A | — | CSS not supported |
|
||||
| `src/index.css` | — | N/A | — | CSS not supported |
|
||||
| `src/components/Editor.css` | — | N/A | — | CSS not supported |
|
||||
| `src/components/EditorTheme.css` | — | N/A | — | CSS not supported |
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt Hotspots
|
||||
|
||||
Based on code health scores, file sizes, and change frequency:
|
||||
|
||||
| Priority | File | Score | LoC | Risk Factor |
|
||||
|----------|------|-------|-----|-------------|
|
||||
| 1 | `src/components/Editor.tsx` | 7.68 | 575 | **Brain Method** (cc=61, 385 LoC) — worst single function in codebase |
|
||||
| 2 | `src/hooks/useNoteActions.ts` | 7.81 | 280 | Brain Method (cc=30, 169 LoC), deep nesting in updateMockFrontmatter |
|
||||
| 3 | `src/components/NoteList.tsx` | 8.05 | 434 | Complex Method (cc=28, 208 LoC) |
|
||||
| 4 | `src/components/AIChatPanel.tsx` | 8.51 | 364 | Large component (cc=15, 285 LoC) — new, address before it grows |
|
||||
| 5 | `src-tauri/src/vault.rs` | 8.81 | 1111 | Near-Green, no code smells — minor improvement needed to cross 9.0 |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Analysis — Files Scoring Below 9.0
|
||||
|
||||
### 1. `src/components/Editor.tsx` — Score: 7.68 (Now #1 Priority)
|
||||
|
||||
The core `Editor` component function remains a **Brain Method** — the single worst function in the codebase at cc=61 and 385 LoC (3.2x the 120 LoC limit).
|
||||
|
||||
**Code Smells Found:**
|
||||
|
||||
| Smell | Location | Details | Severity |
|
||||
|-------|----------|---------|----------|
|
||||
| Bumpy Road | `Editor` (L154–575) | 2 bumps | High |
|
||||
| Complex Method | `Editor` (L154–575) | cc = 61 (**Brain Method**) | High |
|
||||
| Complex Conditional | `Editor:196` | 2 complex expressions | Medium |
|
||||
| Large Method | `Editor` (L154–575) | 385 LoC (limit: 120) | Medium |
|
||||
|
||||
---
|
||||
|
||||
### 2. `src/hooks/useNoteActions.ts` — Score: 7.81
|
||||
|
||||
Extracted from App.tsx. Contains the `updateMockFrontmatter` function which has deep nesting, plus the `useNoteActions` hook itself is still too large.
|
||||
|
||||
**Code Smells Found:**
|
||||
|
||||
| Smell | Location | Details | Severity |
|
||||
|-------|----------|---------|----------|
|
||||
| Bumpy Road | `updateMockFrontmatter` (L14–66) | 2 bumps | High |
|
||||
| Deep Nesting | `updateMockFrontmatter` (L14–66) | 4 levels deep | High |
|
||||
| Complex Method | `useNoteActions` (L93–280) | cc = 30 | Medium |
|
||||
| Complex Method | `updateMockFrontmatter` (L14–66) | cc = 17 | Medium |
|
||||
| Complex Method | `deleteMockFrontmatterProperty` (L68–91) | cc = 9 | Medium |
|
||||
| Large Method | `useNoteActions` (L93–280) | 169 LoC (limit: 70) | Medium |
|
||||
|
||||
---
|
||||
|
||||
### 3. `src/components/NoteList.tsx` — Score: 8.05
|
||||
|
||||
Slightly regressed from 8.11. The `NoteListInner` component and `buildRelationshipGroups` remain complex.
|
||||
|
||||
**Code Smells Found:**
|
||||
|
||||
| Smell | Location | Details | Severity |
|
||||
|-------|----------|---------|----------|
|
||||
| Complex Method | `NoteListInner` (L211–432) | cc = 28 | Medium |
|
||||
| Complex Method | `buildRelationshipGroups` (L125–188) | cc = 13 | Medium |
|
||||
| Large Method | `NoteListInner` (L211–432) | 208 LoC (limit: 120) | Medium |
|
||||
| Overall Code Complexity | File-wide | High mean cyclomatic complexity | Medium |
|
||||
|
||||
---
|
||||
|
||||
### 4. `src/components/AIChatPanel.tsx` — Score: 8.51
|
||||
|
||||
New file (mock AI chat feature). Already showing signs of complexity that should be addressed early.
|
||||
|
||||
**Code Smells Found:**
|
||||
|
||||
| Smell | Location | Details | Severity |
|
||||
|-------|----------|---------|----------|
|
||||
| Complex Method | `AIChatPanel` (L62–364) | cc = 15 | Medium |
|
||||
| Large Method | `AIChatPanel` (L62–364) | 285 LoC (limit: 120) | Medium |
|
||||
|
||||
---
|
||||
|
||||
### 5. `src-tauri/src/vault.rs` — Score: 8.81
|
||||
|
||||
Dramatically improved from 6.22. The CodeScene review reports **zero code smells** after the refactoring. The file is near the Green threshold (9.0) and may only need minor adjustments to cross it.
|
||||
|
||||
---
|
||||
|
||||
## Quick Wins (Low Effort, High Impact)
|
||||
|
||||
### 1. Decompose `Editor` into hooks (highest ROI)
|
||||
**File:** `src/components/Editor.tsx` | **Impact:** cc 61 -> ~10 per hook
|
||||
- Extract `useEditorExtensions()` — all CodeMirror extension setup (themes, keybindings, decorations)
|
||||
- Extract `useEditorContent()` — content loading, saving, dirty state management
|
||||
- Extract `useEditorKeymap()` — custom keymap handlers
|
||||
- The `Editor` component becomes a thin composition + JSX layer
|
||||
|
||||
### 2. Decompose `useNoteActions` hook
|
||||
**File:** `src/hooks/useNoteActions.ts` | **Impact:** cc 30 -> ~8 per hook
|
||||
- Extract `useFrontmatterSync()` — `updateMockFrontmatter` + `deleteMockFrontmatterProperty`
|
||||
- Flatten `updateMockFrontmatter` with early returns and helper functions
|
||||
- Keep `useNoteActions` as pure action dispatch (create, delete, rename)
|
||||
|
||||
### 3. Split `NoteListInner` into sub-components
|
||||
**File:** `src/components/NoteList.tsx` | **Impact:** cc 28 -> ~8 per component
|
||||
- Extract `NoteListItem` component for individual note rendering
|
||||
- Extract `RelationshipGroup` component for grouped entries
|
||||
- Extract `buildRelationshipGroups` to a utility file
|
||||
|
||||
### 4. Extract `AIChatPanel` hooks early
|
||||
**File:** `src/components/AIChatPanel.tsx` | **Impact:** Prevent further complexity growth
|
||||
- Extract `useChatMessages()` — message state, send/receive logic
|
||||
- Extract `ChatMessage` component for individual message rendering
|
||||
|
||||
### 5. Push `vault.rs` past 9.0
|
||||
**File:** `src-tauri/src/vault.rs` | **Impact:** 8.81 -> 9.0+
|
||||
- Minor: reduce string-heavy args further with `&Path` conversions
|
||||
- Minor: simplify any remaining complex expressions
|
||||
|
||||
---
|
||||
|
||||
## Path to 9.5 Overall
|
||||
|
||||
**Current:** 9.33 (28 scored files, sum = 261.20)
|
||||
**Target:** 9.5
|
||||
|
||||
To reach 9.5, all 5 Yellow files must reach at least 9.5:
|
||||
|
||||
| File | Current | Target | Points Needed |
|
||||
|------|---------|--------|---------------|
|
||||
| `vault.rs` | 8.81 | 9.5 | +0.69 |
|
||||
| `Editor.tsx` | 7.68 | 9.5 | +1.82 |
|
||||
| `useNoteActions.ts` | 7.81 | 9.5 | +1.69 |
|
||||
| `NoteList.tsx` | 8.05 | 9.5 | +1.45 |
|
||||
| `AIChatPanel.tsx` | 8.51 | 9.5 | +0.99 |
|
||||
| **Total points needed** | | | **+6.64** |
|
||||
|
||||
**Projected score if all Yellow files reach 9.5:** (261.20 + 6.64) / 28 = **9.57**
|
||||
|
||||
**Recommended execution order for maximum impact:**
|
||||
1. `Editor.tsx` (7.68 -> 9.5) — highest user-facing impact, hook extraction is mechanical
|
||||
2. `useNoteActions.ts` (7.81 -> 9.5) — extracted hook, straightforward decomposition
|
||||
3. `NoteList.tsx` (8.05 -> 9.5) — component extraction
|
||||
4. `AIChatPanel.tsx` (8.51 -> 9.5) — closest to target, prevent drift
|
||||
5. `vault.rs` (8.81 -> 9.5) — near-Green already, minor tweaks
|
||||
|
||||
---
|
||||
|
||||
## Refactoring ROI Summary
|
||||
|
||||
| File | Current | Target | Defect Reduction | Speed Improvement |
|
||||
|------|---------|--------|------------------|-------------------|
|
||||
| `Editor.tsx` | 7.68 | 9.5 | 25–38% | 19–30% |
|
||||
| `useNoteActions.ts` | 7.81 | 9.5 | 24–36% | 18–28% |
|
||||
| `NoteList.tsx` | 8.05 | 9.5 | 22–33% | 16–26% |
|
||||
| `AIChatPanel.tsx` | 8.51 | 9.5 | 18–27% | 13–21% |
|
||||
| `vault.rs` | 8.81 | 9.5 | 10–18% | 8–14% |
|
||||
|
||||
---
|
||||
|
||||
## Files in Good Shape
|
||||
|
||||
These files need no immediate attention:
|
||||
|
||||
**Optimal (10.0):**
|
||||
- `src-tauri/src/main.rs` — 6 LoC, clean entry point
|
||||
- `src-tauri/src/git.rs` — 423 LoC, well-structured
|
||||
- `src/components/EditableValue.tsx` — 167 LoC, clean extracted component
|
||||
- `src/components/ResizeHandle.tsx` — 74 LoC, simple component
|
||||
- `src/components/CreateNoteDialog.tsx` — 99 LoC, clean dialog
|
||||
- `src/components/Toast.tsx` — 28 LoC, minimal component
|
||||
- `src/utils/typeColors.ts` — 37 LoC, simple utility
|
||||
- `src/main.tsx` — 16 LoC, entry point
|
||||
|
||||
**Green (9.0–9.9):**
|
||||
- `src-tauri/src/frontmatter.rs` — 9.68 (up from 6.89! Only: string-heavy args)
|
||||
- `src-tauri/src/lib.rs` — 9.68 (minor: string-heavy args)
|
||||
- `src/hooks/useTheme.ts` — 9.68 (clean hook)
|
||||
- `src/components/QuickOpenPalette.tsx` — 9.55 (minor: cc=16)
|
||||
- `src/utils/wikilinks.ts` — 9.53 (clean utility)
|
||||
- `src/hooks/useVaultLoader.ts` — 9.41 (clean hook)
|
||||
- `src/components/CommitDialog.tsx` — 9.38 (clean component)
|
||||
- `src/mock-tauri.ts` — 9.37 (large but clean)
|
||||
- `src/App.tsx` — 9.28 (dramatically improved from 7.13)
|
||||
- `src/utils/frontmatter.ts` — 9.24 (clean utility)
|
||||
- `src/components/StatusBar.tsx` — 9.23 (slightly regressed from 10.0)
|
||||
- `src/components/Sidebar.tsx` — 9.14 (stable)
|
||||
- `src/components/DiffView.tsx` — 9.09 (clean extracted component)
|
||||
- `src/components/DynamicPropertiesPanel.tsx` — 9.06 (clean extracted component)
|
||||
- `src/components/Inspector.tsx` — 9.02 (dramatically improved from 7.49)
|
||||
|
||||
---
|
||||
|
||||
## What Worked Since Last Report
|
||||
|
||||
The following refactorings from the Feb 17 recommendations were executed:
|
||||
|
||||
1. **App.tsx decomposition** (Plan C) — Extracted `useNoteActions`, `useVaultLoader`, and other hooks. App dropped from cc=56/381 LoC to cc=16/130 LoC. Score: 7.13 -> 9.28.
|
||||
2. **Inspector.tsx decomposition** (Plan D) — Extracted `DynamicPropertiesPanel`, `EditableValue`, and `frontmatter.ts` utility. Score: 7.49 -> 9.02.
|
||||
3. **vault.rs full refactoring** (Plan A) — Extracted `run_git` helper, decomposed `parse_md_file` and `scan_vault_cached`, split large assertion blocks, converted to `&Path` args. Score: 4.80 -> 8.81. **All code smells resolved.**
|
||||
4. **frontmatter.rs full refactoring** (Plan B) — Flattened `update_frontmatter_content`, simplified `to_yaml_value` and `format_yaml_key`, extracted line-parsing helpers. Score: 6.89 -> 9.68. **Yellow -> Green.**
|
||||
5. **Editor.tsx partial decomposition** (Plan B, Steps 2–3) — Extracted `DiffView.tsx` and `wikilinks.ts`. Score: 6.94 -> 7.68.
|
||||
|
||||
## What Still Needs Work
|
||||
|
||||
1. **Editor.tsx** — DiffView and wikilinks were extracted, but the core Editor function was NOT decomposed into hooks. It's now the worst function (cc=61, 385 LoC). Hook extraction (useEditorExtensions, useEditorContent, useEditorKeymap) is the next high-impact target.
|
||||
2. **useNoteActions.ts** — Inherited App.tsx's `updateMockFrontmatter` complexity. Needs decomposition into smaller hooks.
|
||||
3. **NoteList.tsx** — Slight regression, needs component extraction (NoteListItem, RelationshipGroup).
|
||||
4. **AIChatPanel.tsx** — New file already showing complexity. Address early before it grows.
|
||||
5. **vault.rs** — Near-Green at 8.81 with zero code smells. Minor tweaks may push it past 9.0.
|
||||
|
||||
---
|
||||
|
||||
*Report generated by CodeScene MCP analysis on 2026-02-20. For interactive exploration, visit: https://codescene.io/projects/76865*
|
||||
*Note: CodeScene MCP Server MCP-0.1.5 was used. Version MCP-0.2.0 is available — consider updating via `brew upgrade cs-mcp`.*
|
||||
362
REDESIGN-PLAN.md
362
REDESIGN-PLAN.md
@@ -1,362 +0,0 @@
|
||||
# Laputa App Redesign — Implementation Plan
|
||||
|
||||
> Generated from `ui-design.pen` (V2) vs current implementation. **Analysis only — do not implement yet.**
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
The V2 design introduces: a **Status Bar**, **Tab Bar** in the editor, an **Info Bar** (breadcrumb + actions), restructured **Sidebar** with Phosphor icons and collapsible groups with count badges, **IBM Plex Mono** for type pills, updated **color palette** (new primary `#155DFF`, new accent colors), and several layout/spacing refinements throughout.
|
||||
|
||||
---
|
||||
|
||||
## Design Specs Reference (from .pen file)
|
||||
|
||||
### Colors Changed
|
||||
| Variable | Old (Light) | New (Light) | Old (Dark) | New (Dark) |
|
||||
|---|---|---|---|---|
|
||||
| `--primary` | `#2383E2` | `#155DFF` | `#4a9eff` | `#155DFF` |
|
||||
| `--accent-green` | `#0F7B6C` | `#00B38B` | `#4caf50` | `#00B38B` |
|
||||
| `--accent-purple` | `#9065B0` | `#A932FF` | `#9c72ff` | `#A932FF` |
|
||||
| `--accent-blue` | `#2383E2` | `#155DFF` | `#4a9eff` | `#155DFF` |
|
||||
|
||||
### New Color Variables (not in current CSS)
|
||||
| Variable | Light | Dark |
|
||||
|---|---|---|
|
||||
| `--accent-yellow` | `#F0B100` | `#F0B100` |
|
||||
| `--accent-blue-light` | `#155DFF14` | `#155DFF20` |
|
||||
| `--accent-green-light` | `#00B38B14` | `#00B38B20` |
|
||||
| `--accent-purple-light` | `#A932FF14` | `#A932FF20` |
|
||||
| `--accent-red-light` | `#E03E3E14` | `#f4433620` |
|
||||
| `--accent-yellow-light` | `#F0B10014` | `#F0B10020` |
|
||||
|
||||
### Typography
|
||||
- **Font**: Inter (primary), IBM Plex Mono (labels/pills) — **IBM Plex Mono not currently loaded**
|
||||
- App title: 17px / Bold / letter-spacing -0.3
|
||||
- Sidebar items: 13px / Medium (font-weight 500)
|
||||
- Sidebar section headers: 13px / Semibold (600) — currently 11px
|
||||
- Type pills: 11px / IBM Plex Mono / normal weight / ALL CAPS
|
||||
- Editor H1: 32px / Bold / lh 1.2
|
||||
- Editor H2: 24px / Semibold / lh 1.3
|
||||
- Editor body: 16px / Regular / lh 1.6
|
||||
- Info bar / breadcrumb: 12px
|
||||
- Status bar: 11px
|
||||
|
||||
### Panel Widths
|
||||
| Panel | Design | Current |
|
||||
|---|---|---|
|
||||
| Sidebar | 250px | 250px ✅ |
|
||||
| NoteList | 300px | 300px ✅ |
|
||||
| Editor | flexible | flexible ✅ |
|
||||
| Inspector | 260px (design) / 280px (spec) | 280px ✅ |
|
||||
|
||||
### Border Radius Scale
|
||||
- 4px (sm) — chips
|
||||
- 6px (md) — buttons, inputs
|
||||
- 8px (lg) — cards, dialogs
|
||||
- 9999px — pills, badges (full-round)
|
||||
- 16px — larger badges
|
||||
|
||||
---
|
||||
|
||||
## Difference Map
|
||||
|
||||
### 1. NEW: Status Bar (bottom of app)
|
||||
**Files**: New component `StatusBar.tsx`, `App.tsx`, `App.css`
|
||||
- 30px height, `bg: --sidebar`, `border-top: 1px --border`
|
||||
- **Left**: box icon + "v0.4.2" | git-branch + "main" | refresh-cw (green) + "Synced 2m ago"
|
||||
- **Right**: sparkles (purple) + "Claude Sonnet 4" | file-text + "1,247 notes" | bell icon | settings icon
|
||||
- Padding: 0 8px, items aligned center, gap 12px between items
|
||||
- Font: Inter 11px, text color `--muted-foreground`
|
||||
- Separators: "|" in `--border` color
|
||||
- **All icons**: Lucide, 13-14px
|
||||
|
||||
### 2. NEW: Tab Bar (top of editor panel)
|
||||
**Files**: `Editor.tsx`
|
||||
- 45px height, `bg: --sidebar`, `border-bottom: 1px --sidebar-border`
|
||||
- **Active tab**: `bg: --background`, border-right 1px `--border`, text 12px/500 `--foreground`, X close icon (14px lucide)
|
||||
- **Inactive tab**: no fill, border-right + border-bottom 1px `--sidebar-border`, text 12px/normal `--muted-foreground`, X icon opacity 0 (show on hover)
|
||||
- **Spacer**: fills remaining width, border-bottom 1px `--border`
|
||||
- **Controls area** (right): border-left + border-bottom 1px `--border`, gap 12px, padding 0 12px
|
||||
- Plus icon (Phosphor, 16px)
|
||||
- Columns/split icon (Phosphor, 16px) — **disabled placeholder**
|
||||
- Arrows-out-simple/expand icon (Phosphor, 16px) — **disabled placeholder**
|
||||
|
||||
### 3. NEW: Breadcrumb Bar (below tab bar, above editor content)
|
||||
**Files**: `Editor.tsx`
|
||||
- 45px height, `bg: --background`, `border-bottom: 1px --border`
|
||||
- Padding: 6px 16px
|
||||
- **Left (breadcrumb)**: "Project" (12px, muted) › "Laputa App" (12px/500, foreground) · "1,284 words" (12px, muted) · "M" (12px/600, `--accent-yellow`) — M only when file modified
|
||||
- **Right (actions)**: gap 12px, each 16px Phosphor icon in `--muted-foreground`
|
||||
- magnifying-glass (search in file)
|
||||
- git-branch (version history) — **disabled placeholder**
|
||||
- cursor-text (focus mode) — **disabled placeholder**
|
||||
- sparkle (AI assist) — **disabled placeholder**
|
||||
- dots-three (more options) — **disabled placeholder**
|
||||
|
||||
### 4. Sidebar Restructure
|
||||
**Files**: `Sidebar.tsx`
|
||||
|
||||
#### Header changes:
|
||||
- Current: "Laputa" title + theme toggle button
|
||||
- New: "Laputa" title (17px/700, -0.3 ls) + search icon (16px Phosphor magnifying-glass) + settings/gear icon (16px)
|
||||
- Theme toggle moved elsewhere (or removed from header)
|
||||
- Padding: 12px 16px, height 45px, border-bottom 1px
|
||||
|
||||
#### Search bar added:
|
||||
- Below header, padding 6px 12px, border-bottom 1px
|
||||
- Input with magnifying-glass icon prefix, 13px text, placeholder "Search notes..."
|
||||
- Height ~32px, border-radius 6px, bg `--secondary`
|
||||
|
||||
#### Navigation section restructured:
|
||||
**Current**: flat list of filters (All Notes, People, Events, Changes, Favorites, Trash)
|
||||
**New**: Two items in top nav:
|
||||
- "All Notes" — file-text icon (Phosphor 16px) + label 13px/500 + count badge (pill, bg `--secondary`, 10px text)
|
||||
- "Favorites" — star icon (Phosphor 16px) + same style
|
||||
|
||||
#### Section groups restructured:
|
||||
**Current**: PROJECTS, EXPERIMENTS, RESPONSIBILITIES, PROCEDURES as expandable sections with items listed under each
|
||||
**New**: Collapsible groups with consistent pattern:
|
||||
- Each group: chevron-right (12px Lucide) + icon (18px Phosphor, bold) + label (13px/600) + count badge (pill)
|
||||
- **Projects** — folder-open icon (Phosphor)
|
||||
- **Experiments** — flask icon (Phosphor)
|
||||
- **Responsibilities** — target icon (Phosphor) — **currently not in sidebar**
|
||||
- **Procedures** — arrows-clockwise icon (Phosphor)
|
||||
- **People** — users icon (Phosphor) — **moved from filter to section group**
|
||||
- **Events** — calendar-blank icon (Phosphor) — **moved from filter to section group**
|
||||
- **Topics** — tag icon (Phosphor) — **currently at bottom, now integrated as a group**
|
||||
|
||||
Each group has:
|
||||
- Container: padding 4px 6px, border-bottom 1px (disabled in some), vertical layout, gap 2px
|
||||
- Header row: padding 6px 16px, corner-radius 4px, gap 8px, justify space-between
|
||||
- Badge: height 20px, bg `--secondary`, corner-radius 9999px, padding 0 6px
|
||||
|
||||
#### Removed from sidebar:
|
||||
- "Untagged" filter — not in new design
|
||||
- "Changes" filter — not in new design (modified files shown elsewhere)
|
||||
- "Trash" filter — not in new design
|
||||
- "People" as top-level filter — now a collapsible section group
|
||||
- "Events" as top-level filter — now a collapsible section group
|
||||
|
||||
#### Commit button:
|
||||
- Same concept but refined: padding 12px, border-top 1px
|
||||
- Button: fill `--primary`, corner-radius 6px, gap 6px, padding 8px 16px
|
||||
- Icon: git-commit-horizontal (Lucide 14px) in `--primary-foreground`
|
||||
- Text: "Commit & Push" (13px/500)
|
||||
- Badge: bg `#ffffff40`, corner-radius 9px, text `--white` 10px/600
|
||||
|
||||
### 5. NoteList Changes
|
||||
**Files**: `NoteList.tsx`
|
||||
|
||||
#### Header:
|
||||
- Current: title + count badge + create button
|
||||
- New: "Notes" title (14px/600) + search icon (16px Phosphor) + plus icon (16px Phosphor) — gap 12px
|
||||
- No separate count badge in header
|
||||
|
||||
#### Search:
|
||||
- Current: always-visible search input below header
|
||||
- New: search icon in header (search may toggle inline or use command palette)
|
||||
- **Remove the always-visible search input** or keep it hidden until search icon clicked
|
||||
|
||||
#### Type pills:
|
||||
- Current: rounded-full, border, `text-[11px]`, system font, "Projects 4" format
|
||||
- New: `IBM Plex Mono` font, 11px, ALL CAPS format "ALL 24" / "PROJECTS 4" / "NOTES 12" / "EVENTS 5"
|
||||
- Active pill: `bg: #4a9eff18` (blue tint), `border: 1px --primary`, text `--primary`
|
||||
- Inactive pill: `border: 1px --border`, text `--muted-foreground`
|
||||
- Pill padding: 2px 10px, corner-radius 9999px
|
||||
- Height: ~18px (compact)
|
||||
- Layout: absolute positioned at x offsets (12, 76, 166, 243) within 45px height container — effectively a horizontal scrollable row
|
||||
|
||||
#### Note items:
|
||||
- Selected: `bg: #2383E212` (very light blue), left accent bar 3px `#2383E2`, title 13px/600
|
||||
- Normal: border-bottom 1px `#E9E9E7`, title 13px/500, time 11px, snippet 12px/lh1.5
|
||||
- Padding: 10px 16px
|
||||
- **No type badge** on individual items (simplified)
|
||||
- **No status text** on items
|
||||
|
||||
### 6. Editor Content Area
|
||||
**⚠️ SKIP — Keep editor as-is. Editor changes in the design are NOT intentional.**
|
||||
|
||||
### 7. Inspector Refinements
|
||||
**Files**: `Inspector.tsx`
|
||||
|
||||
#### Header:
|
||||
- Current: collapsed toggle + title
|
||||
- New: sliders-horizontal icon (16px Phosphor) + "Properties" (13px/600, `--muted-foreground`) + X close button (16px Phosphor)
|
||||
- Height 45px, border-bottom 1px, padding 0 12px, gap 8px
|
||||
|
||||
#### Properties section:
|
||||
- Key-value rows: label (12px, muted) — value (12px, foreground), space-between
|
||||
- Status badge: colored bg (e.g., `--accent-green-light`) with colored text (e.g., `--accent-green`), rounded, padding 1px 6px, 10px font
|
||||
- "+ Add property" button: full-width, border 1px `--border`, corner-radius 6px, padding 6px 12px, centered text (12px, muted)
|
||||
|
||||
#### Relationships section:
|
||||
- Group title: 12px/600 foreground
|
||||
- Link buttons: full-width, bg `--accent-blue-light`, corner-radius 6px, padding 6px 10px, text `--primary` 12px/500, icon (tag/flask, Phosphor 14px, 0.5 opacity)
|
||||
- "+ Link existing" button: border 1px `--border`, corner-radius 6px, same padding
|
||||
|
||||
#### Backlinks:
|
||||
- Title: "Backlinks" 12px/600 + count 11px/500 muted
|
||||
- Items: text `--primary` 12px
|
||||
|
||||
#### History:
|
||||
- Title: "History" 12px/600
|
||||
- Items: left border 2px `--border`, padding-left 10px
|
||||
- Hash line: 11px foreground
|
||||
- Date line: 10px muted
|
||||
|
||||
### 8. Icon Library Change
|
||||
**Current**: Lucide React throughout
|
||||
**New**: **SF Symbols** (Apple's native icon set) for all new/redesigned icons. Use `sf-symbols-react` or inline SVGs extracted from SF Symbols app.
|
||||
|
||||
**Note**: The Pencil design used Phosphor as a placeholder — Luca's intent is SF Symbols throughout. Map Phosphor names to SF Symbol equivalents:
|
||||
- `magnifying-glass` → `magnifyingglass`
|
||||
- `star` → `star.fill`
|
||||
- `folder-open` → `folder`
|
||||
- `flask` → `flask`
|
||||
- `target` → `target`
|
||||
- `arrows-clockwise` → `arrow.clockwise`
|
||||
- `users` → `person.2`
|
||||
- `calendar-blank` → `calendar`
|
||||
- `tag` → `tag`
|
||||
- `plus` → `plus`
|
||||
- `columns` → `rectangle.split.2x1`
|
||||
- `arrows-out-simple` → `arrow.up.left.and.arrow.down.right`
|
||||
- `sliders-horizontal` → `slider.horizontal.3`
|
||||
- `cursor-text` → `character.cursor.ibeam`
|
||||
- `sparkle` → `sparkles`
|
||||
- `dots-three` → `ellipsis`
|
||||
- `git-branch` → `arrow.triangle.branch`
|
||||
- `gear` → `gearshape`
|
||||
|
||||
**Action**: Find the best approach for SF Symbols in React/Tauri (e.g., `sf-symbols-react`, SVG extraction, or native font)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Theme & Typography Updates
|
||||
**Scope**: CSS variables, fonts, colors — no structural changes
|
||||
**Files**: `src/index.css`, `index.html` (or font import)
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **Add IBM Plex Mono font** — add Google Fonts import or npm package
|
||||
2. **Update color variables in `index.css`**:
|
||||
- `:root` (light): `--primary: #155DFF`, `--accent-green: #00B38B`, `--accent-purple: #A932FF`, `--accent-blue: #155DFF`
|
||||
- `.dark`: same primary `#155DFF`, accent-green `#00B38B`, accent-purple `#A932FF`
|
||||
- Add new variables: `--accent-yellow`, `--accent-blue-light`, `--accent-green-light`, `--accent-purple-light`, `--accent-red-light`, `--accent-yellow-light` (both modes)
|
||||
- Update all `--ring`, `--sidebar-primary`, `--sidebar-ring` to match new primary
|
||||
- Update app-specific vars: `--accent-blue`, `--accent-green`, `--accent-purple`, `--accent-blue-bg` etc.
|
||||
3. **Update `theme.json`**:
|
||||
- `headings.h2.fontSize`: 27 → 24
|
||||
- `editor.paddingHorizontal`: 40 → 64
|
||||
- `editor.paddingVertical`: 20 → 32
|
||||
4. **Install Phosphor Icons**: `pnpm add @phosphor-icons/react`
|
||||
|
||||
### Phase 2: Sidebar Restructure
|
||||
**Scope**: Sidebar layout, navigation, icons
|
||||
**Files**: `src/components/Sidebar.tsx`
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **Header**: Replace theme toggle with search icon (Phosphor `MagnifyingGlass`) + gear icon. Move theme toggle to status bar settings or a menu.
|
||||
2. **Add search input** below header: Phosphor magnifying-glass prefix, 13px, bg `--secondary`, border-radius 6px
|
||||
3. **Top nav**: Reduce to "All Notes" (Phosphor `FileText` 16px) and "Favorites" (Phosphor `Star` 16px), each with count badge pill
|
||||
4. **Section groups**: Restructure to new pattern with:
|
||||
- Consistent chevron + Phosphor icon (18px, bold) + label (13px/600) + count badge
|
||||
- Icons: `FolderOpen` (Projects), `Flask` (Experiments), `Target` (Responsibilities), `ArrowsClockwise` (Procedures), `Users` (People), `CalendarBlank` (Events), `Tag` (Topics)
|
||||
- Move People and Events from filters to section groups
|
||||
- Remove "Untagged", "Changes", "Trash" from nav
|
||||
5. **Commit button**: Update styling to match design (padding, badge style)
|
||||
6. **Remove** People/Events/Changes/Trash/Untagged filter items
|
||||
|
||||
### Phase 3: NoteList Updates
|
||||
**Scope**: Header, type pills, note item styling
|
||||
**Files**: `src/components/NoteList.tsx`
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **Header**: Replace badge + create button with search icon (Phosphor `MagnifyingGlass`) + plus icon (Phosphor `Plus`), gap 12px
|
||||
2. **Remove or hide** the always-visible search input — add toggle behavior on search icon click
|
||||
3. **Type pills**: Switch to IBM Plex Mono, ALL CAPS format ("ALL 24", "PROJECTS 4"), update active/inactive styles per design
|
||||
4. **Selected note**: Update to `bg: #2383E212`, left accent 3px `#2383E2` (update to new primary), remove type badge and status text from items
|
||||
5. **Note items**: Adjust padding to 10px 16px, snippet line-height 1.5, remove type/status badges from individual items
|
||||
|
||||
### Phase 4: Editor — Tab Bar & Info Bar
|
||||
**Scope**: New sub-components within Editor
|
||||
**Files**: `src/components/Editor.tsx`, `src/components/Editor.css`
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **Tab Bar** (top of editor):
|
||||
- 45px, bg `--sidebar`, border-bottom
|
||||
- Active tab: bg `--background`, border-right, 12px/500 text, X close button
|
||||
- Inactive tab: muted text, hidden X (show on hover)
|
||||
- Right controls: Plus + Split (disabled) + Expand (disabled) — Phosphor icons
|
||||
2. **Info Bar** (below tab bar):
|
||||
- 45px, bg `--background`, border-bottom
|
||||
- Left: breadcrumb `Type › Title · N words · M` (M in accent-yellow when modified)
|
||||
- Right: icon buttons (magnifying-glass functional, git-branch/cursor-text/sparkle/dots-three as **disabled placeholders** with `opacity: 0.4, cursor: not-allowed`)
|
||||
3. **Adjust editor content padding** to 32px 64px per design
|
||||
|
||||
### Phase 5: Status Bar + Inspector Polish
|
||||
**Scope**: New StatusBar component, Inspector refinements
|
||||
**Files**: New `src/components/StatusBar.tsx`, `App.tsx`, `App.css`, `src/components/Inspector.tsx`
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **StatusBar.tsx** (new component):
|
||||
- 30px fixed at bottom, bg `--sidebar`, border-top 1px
|
||||
- Left: version + branch + sync status
|
||||
- Right: AI model + notes count + bell (disabled placeholder) + settings (disabled placeholder)
|
||||
- All Lucide icons 13-14px
|
||||
2. **App.tsx / App.css**: Add StatusBar below main content, wrap layout in vertical flex (main panels + status bar)
|
||||
3. **Inspector refinements**:
|
||||
- Header: Phosphor `SlidersHorizontal` icon + "Properties" label + Phosphor `X` close
|
||||
- Status badge: use `--accent-*-light` bg colors with `--accent-*` text
|
||||
- "+ Add property" and "+ Link existing" buttons: match border/radius/padding from design
|
||||
- History items: left-border 2px timeline style, 10px date text
|
||||
|
||||
### Phase 6: Icon Migration & Cleanup
|
||||
**Scope**: Replace Lucide icons with Phosphor where specified
|
||||
**Files**: All components
|
||||
**Estimated effort**: 1 Claude Code session
|
||||
|
||||
1. **Audit all icon usage** across components
|
||||
2. **Replace with Phosphor** where the design specifies (sidebar nav, section icons, NoteList header, editor toolbar icons, inspector)
|
||||
3. **Keep Lucide** for: chevrons, X/close, tab close, status bar icons, git-commit-horizontal
|
||||
4. **Remove unused Lucide imports**
|
||||
5. **Visual verification**: Run `pnpm dev` and compare with `ui-design-screenshot.png`
|
||||
|
||||
---
|
||||
|
||||
## New Features as Disabled Placeholders
|
||||
|
||||
These buttons/icons appear in the design but don't have backend functionality yet. Add them as disabled UI elements:
|
||||
|
||||
| Element | Location | Icon | Notes |
|
||||
|---|---|---|---|
|
||||
| Split view | Tab bar controls | Phosphor `Columns` | `opacity: 0.4, cursor: not-allowed, title="Coming soon"` |
|
||||
| Expand/focus | Tab bar controls | Phosphor `ArrowsOutSimple` | Same |
|
||||
| Git branch viewer | Info bar right | Phosphor `GitBranch` | Same |
|
||||
| Focus mode | Info bar right | Phosphor `CursorText` | Same |
|
||||
| AI assist | Info bar right | Phosphor `Sparkle` | Same |
|
||||
| More options | Info bar right | Phosphor `DotsThree` | Same |
|
||||
| Bell/notifications | Status bar right | Lucide `Bell` | Same |
|
||||
| Settings | Status bar right | Lucide `Settings` | Same |
|
||||
| Gear/settings | Sidebar header | Phosphor `Gear` | Same |
|
||||
|
||||
---
|
||||
|
||||
## Files Inventory
|
||||
|
||||
| File | Changes |
|
||||
|---|---|
|
||||
| `src/index.css` | Color variables, font import |
|
||||
| `src/theme.json` | H2 size, editor padding |
|
||||
| `index.html` | IBM Plex Mono font link (if using CDN) |
|
||||
| `package.json` | Add `@phosphor-icons/react` |
|
||||
| `src/App.tsx` | Add StatusBar, adjust layout |
|
||||
| `src/App.css` | Vertical flex for status bar |
|
||||
| `src/components/Sidebar.tsx` | Major restructure |
|
||||
| `src/components/NoteList.tsx` | Header, pills, item styling |
|
||||
| `src/components/Editor.tsx` | Add TabBar, InfoBar sections |
|
||||
| `src/components/Editor.css` | Tab/info bar styles |
|
||||
| `src/components/Inspector.tsx` | Header, badges, history styling |
|
||||
| `src/components/StatusBar.tsx` | **NEW** |
|
||||
@@ -1,130 +0,0 @@
|
||||
# SF Symbols Migration Plan
|
||||
|
||||
> Current state: All icons use either **Phosphor Icons** (`@phosphor-icons/react`) or **Lucide React** (`lucide-react`). This document maps every icon to its SF Symbol equivalent for future migration.
|
||||
|
||||
---
|
||||
|
||||
## Icon Audit Summary (Phase 6 — 2026-02-17)
|
||||
|
||||
| Category | Count | Files | Status |
|
||||
|---|---|---|---|
|
||||
| Phosphor icons | 22 | `Sidebar.tsx`, `Editor.tsx`, `NoteList.tsx`, `Inspector.tsx` | All used, migrate to SF Symbols |
|
||||
| Phosphor types | 1 (`IconProps`) | `Sidebar.tsx` | Type only — replace when migrating |
|
||||
| Lucide (app components) | 4 | `Sidebar.tsx`, `Editor.tsx` | Evaluate per-icon |
|
||||
| Lucide (StatusBar) | 7 | `StatusBar.tsx` | Keep Lucide per design |
|
||||
| Lucide (shadcn/ui) | 7 | `ui/select.tsx`, `ui/dropdown-menu.tsx`, `ui/dialog.tsx` | Keep Lucide — library internals |
|
||||
| **Total icon imports** | **41** | **8 files** | **0 unused** |
|
||||
|
||||
**Unused imports found**: None. All icon imports are actively used in JSX.
|
||||
|
||||
---
|
||||
|
||||
## Phosphor Icons — Current Usage
|
||||
|
||||
These are the primary UI icons introduced during the redesign. All should migrate to SF Symbols.
|
||||
|
||||
| Phosphor Icon | SF Symbol Equivalent | File(s) | Usage |
|
||||
|---|---|---|---|
|
||||
| `MagnifyingGlass` | `magnifyingglass` | `Sidebar.tsx`, `NoteList.tsx`, `Editor.tsx` | Search icon in sidebar header, note list header, editor info bar |
|
||||
| `Gear` | `gearshape` | `Sidebar.tsx` | Settings icon in sidebar header (disabled placeholder) |
|
||||
| `FileText` | `doc.text` | `Sidebar.tsx` | "All Notes" nav item icon |
|
||||
| `Star` | `star.fill` | `Sidebar.tsx` | "Favorites" nav item icon |
|
||||
| `FolderOpen` | `folder` | `Sidebar.tsx` | "Projects" section group icon |
|
||||
| `Flask` | `flask` | `Sidebar.tsx` | "Experiments" section group icon |
|
||||
| `Target` | `target` | `Sidebar.tsx` | "Responsibilities" section group icon |
|
||||
| `ArrowsClockwise` | `arrow.clockwise` | `Sidebar.tsx` | "Procedures" section group icon |
|
||||
| `Users` | `person.2` | `Sidebar.tsx` | "People" section group icon |
|
||||
| `CalendarBlank` | `calendar` | `Sidebar.tsx` | "Events" section group icon |
|
||||
| `Tag` | `tag` | `Sidebar.tsx` | "Topics" section group icon |
|
||||
| `TagSimple` | `tag` | `Sidebar.tsx` | "Untagged" nav item icon |
|
||||
| `Trash` | `trash` | `Sidebar.tsx` | "Trash" nav item icon |
|
||||
| `Plus` | `plus` | `NoteList.tsx`, `Editor.tsx` | Create note button, new tab button |
|
||||
| `Columns` | `rectangle.split.2x1` | `Editor.tsx` | Split view button (disabled placeholder) |
|
||||
| `ArrowsOutSimple` | `arrow.up.left.and.arrow.down.right` | `Editor.tsx` | Expand/focus button (disabled placeholder) |
|
||||
| `GitBranch` | `arrow.triangle.branch` | `Editor.tsx` | Version history button (disabled placeholder) |
|
||||
| `CursorText` | `character.cursor.ibeam` | `Editor.tsx` | Focus mode button (disabled placeholder) |
|
||||
| `Sparkle` | `sparkles` | `Editor.tsx` | AI assist button (disabled placeholder) |
|
||||
| `DotsThree` | `ellipsis` | `Editor.tsx` | More options button (disabled placeholder) |
|
||||
| `SlidersHorizontal` | `slider.horizontal.3` | `Inspector.tsx` | Inspector header icon |
|
||||
| `X` (Phosphor) | `xmark` | `Inspector.tsx` | Inspector close button |
|
||||
| `IconProps` (type) | n/a | `Sidebar.tsx` | TypeScript type for icon component props |
|
||||
|
||||
---
|
||||
|
||||
## Lucide React — Current Usage
|
||||
|
||||
### App Components
|
||||
|
||||
These Lucide icons are used in custom app components. Some may migrate to SF Symbols; others are kept for specific reasons.
|
||||
|
||||
| Lucide Icon | SF Symbol Equivalent | File | Usage | Migration Notes |
|
||||
|---|---|---|---|---|
|
||||
| `ChevronRight` | `chevron.right` | `Sidebar.tsx` | Section group expand chevron | Keep Lucide or migrate — small utility icon |
|
||||
| `ChevronDown` | `chevron.down` | `Sidebar.tsx` | Section group collapse chevron | Keep Lucide or migrate — small utility icon |
|
||||
| `GitCommitHorizontal` | `circle.dotted` | `Sidebar.tsx` | Commit & Push button icon | Keep Lucide or migrate |
|
||||
| `X` (Lucide) | `xmark` | `Editor.tsx` | Tab close button | Keep Lucide or migrate |
|
||||
| `Package` | `shippingbox` | `StatusBar.tsx` | App version indicator | Keep Lucide — status bar uses Lucide per design |
|
||||
| `GitBranch` (Lucide) | `arrow.triangle.branch` | `StatusBar.tsx` | Git branch indicator | Keep Lucide — status bar uses Lucide per design |
|
||||
| `RefreshCw` | `arrow.clockwise` | `StatusBar.tsx` | Sync status indicator | Keep Lucide — status bar uses Lucide per design |
|
||||
| `Sparkles` (Lucide) | `sparkles` | `StatusBar.tsx` | AI model indicator | Keep Lucide — status bar uses Lucide per design |
|
||||
| `FileText` (Lucide) | `doc.text` | `StatusBar.tsx` | Notes count indicator | Keep Lucide — status bar uses Lucide per design |
|
||||
| `Bell` | `bell` | `StatusBar.tsx` | Notifications (disabled placeholder) | Keep Lucide — status bar uses Lucide per design |
|
||||
| `Settings` | `gearshape` | `StatusBar.tsx` | Settings (disabled placeholder) | Keep Lucide — status bar uses Lucide per design |
|
||||
|
||||
### shadcn/ui Components (Keep Lucide)
|
||||
|
||||
These are standard shadcn/ui library components that use Lucide as their built-in icon system. These should **not** be migrated — they are part of the component library's internal implementation.
|
||||
|
||||
| Lucide Icon | File | Usage |
|
||||
|---|---|---|
|
||||
| `CheckIcon` | `ui/select.tsx` | Selected item indicator |
|
||||
| `ChevronDownIcon` | `ui/select.tsx` | Select trigger arrow, scroll-down button |
|
||||
| `ChevronUpIcon` | `ui/select.tsx` | Scroll-up button |
|
||||
| `CheckIcon` | `ui/dropdown-menu.tsx` | Checkbox item indicator |
|
||||
| `ChevronRightIcon` | `ui/dropdown-menu.tsx` | Sub-menu trigger arrow |
|
||||
| `CircleIcon` | `ui/dropdown-menu.tsx` | Radio item indicator |
|
||||
| `XIcon` | `ui/dialog.tsx` | Dialog close button |
|
||||
|
||||
---
|
||||
|
||||
## Approach Options for SF Symbols in React/Tauri
|
||||
|
||||
### Option 1: `sf-symbols-react` npm package
|
||||
- **Pros**: Drop-in React components, familiar API (`<SFSymbol name="magnifyingglass" />`)
|
||||
- **Cons**: Third-party package, may lag behind Apple's symbol updates, limited weight/rendering options
|
||||
- **Status**: Check npm for current maintenance state before adopting
|
||||
|
||||
### Option 2: SVG extraction from SF Symbols app
|
||||
- **Pros**: Exact Apple-quality vectors, no runtime dependency, full control over styling
|
||||
- **Cons**: Manual export process per icon, potential licensing concerns (SF Symbols license restricts use to Apple platforms), need to manage SVG sprite or individual files
|
||||
- **How**: Export SVGs from the SF Symbols macOS app, create a `src/icons/` directory with individual SVG components or a sprite sheet
|
||||
|
||||
### Option 3: Apple's SF Symbols font (native approach via Tauri)
|
||||
- **Pros**: Pixel-perfect on macOS, automatic weight matching, system-native feel
|
||||
- **Cons**: Only works on macOS (not cross-platform), requires Tauri native font access, won't render in browser dev mode
|
||||
- **How**: Use CSS `font-family: "SF Pro"` with Unicode code points, or invoke native APIs from Tauri's Rust backend
|
||||
|
||||
### Option 4: Hybrid — SVG in browser, native in Tauri
|
||||
- **Pros**: Best of both worlds — browser dev mode uses SVGs, production Tauri build uses native SF Symbols
|
||||
- **Cons**: More complex build setup, need to maintain two icon systems
|
||||
- **How**: Build an `<Icon>` wrapper component that checks `window.__TAURI__` and renders native or SVG accordingly
|
||||
|
||||
### Recommendation
|
||||
**Option 2 (SVG extraction)** is the most practical starting point:
|
||||
- Laputa is a macOS-only Tauri app, so SF Symbols licensing applies (Apple platform)
|
||||
- SVGs work in both browser dev mode and Tauri production
|
||||
- No third-party dependency to maintain
|
||||
- Can later upgrade to Option 4 (hybrid native) for perfect macOS integration
|
||||
|
||||
---
|
||||
|
||||
## Migration Steps (Future)
|
||||
|
||||
1. Export all needed SF Symbol SVGs from the SF Symbols macOS app
|
||||
2. Create `src/icons/sf-symbols/` with a React component per icon (or a single sprite)
|
||||
3. Build a thin `<SFIcon name="..." size={} />` wrapper for consistent API
|
||||
4. Replace Phosphor imports file-by-file (Sidebar → NoteList → Editor → Inspector)
|
||||
5. Decide whether to also replace Lucide in StatusBar and utility icons (chevrons, X)
|
||||
6. Keep Lucide in shadcn/ui components — do not modify those
|
||||
7. Once all Phosphor icons are replaced, remove `@phosphor-icons/react` from dependencies
|
||||
8. Run `pnpm build` and visually verify all icons render correctly
|
||||
53
VISION.md
53
VISION.md
@@ -1,53 +0,0 @@
|
||||
# Laputa — Vision
|
||||
|
||||
Laputa is a personal knowledge base where humans and AI agents collaborate as equals.
|
||||
|
||||
---
|
||||
|
||||
## Core principles
|
||||
|
||||
### 1. The vault is the source of truth
|
||||
Everything lives in the vault as plain text files. Notes, relations, configuration, instructions — all Markdown with YAML frontmatter. No proprietary database, no hidden state. If you can open a terminal, you can read your vault. If you can write Markdown, you can modify it.
|
||||
|
||||
### 2. Vault-native configuration
|
||||
Laputa configures itself through files inside the vault — the same files you write and read every day. There is no separate "settings app" or admin panel. If you want to change a theme, you edit a note. If you want to give instructions to an AI agent, you write a note. If you want to define a template, you create a note.
|
||||
|
||||
This applies to:
|
||||
- **`_themes/`** — themes as notes with a YAML block in the body. Edit `_themes/dark.md`, see the colors change in real time.
|
||||
- **`AGENTS.md`** — instructions for AI agents. Write what you want them to know about your vault in plain language. They read it before acting.
|
||||
- **`_templates/`** — note templates per type. Create `_templates/event.md` and every new event starts from that structure.
|
||||
- **`_procedures/`** — recurring tasks as notes with a `schedule` frontmatter field.
|
||||
|
||||
The principle: **if it can be expressed in frontmatter + Markdown, it doesn't need a UI**.
|
||||
|
||||
### 3. Structure through types, not folders
|
||||
Notes have a `type` field. Types determine folders, icons, and colors — but the structure is defined by the data, not the filesystem hierarchy. You can query "all events in February" without knowing anything about folder layout.
|
||||
|
||||
Relations between notes are expressed as frontmatter arrays: `people: [Marco, Sara]`. A wikilink `[[Marco]]` in the body navigates to the person note. The graph emerges from the data, not from a separate graph database.
|
||||
|
||||
### 4. The file is the interface
|
||||
You can use Laputa's UI, or you can open a terminal. Or a text editor. Or Claude Code. They all operate on the same files. There is no difference between "the app" and "the vault" — the vault is the app.
|
||||
|
||||
This is why Laputa has an MCP server: external agents get the same tools the in-app AI panel uses. The interface is a convenience, not a requirement.
|
||||
|
||||
### 5. Humans and AI as collaborators
|
||||
Pulse — the activity feed — shows the history of the vault without distinguishing between human commits and agent commits. That's intentional. Laputa is designed to be a space where you and your AI agents work together, each contributing to the same knowledge base.
|
||||
|
||||
The AI doesn't have a separate workspace. It works in yours.
|
||||
|
||||
---
|
||||
|
||||
## What Laputa is not
|
||||
|
||||
- Not a todo app (though you can use it as one)
|
||||
- Not a note-taking app that syncs to the cloud (the vault is yours, sync however you want — git, iCloud, rsync)
|
||||
- Not a replacement for a terminal (power users will use both)
|
||||
- Not trying to abstract away git (git is a feature, not an implementation detail)
|
||||
|
||||
---
|
||||
|
||||
## The long game
|
||||
|
||||
A vault that grows with you for years. Events, people, projects, thoughts — all interconnected, all version-controlled, all accessible to any tool that can read a file.
|
||||
|
||||
Ten years from now, your vault should still be readable. Plain text is forever.
|
||||
Binary file not shown.
@@ -1,41 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyze which broken links are to existing vs non-existent notes."""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, '/Users/luca/Workspace/laputa-app')
|
||||
from select_demo_notes import build_graph, select_notes, LAPUTA_ROOT
|
||||
|
||||
nodes, link_lookup = build_graph()
|
||||
selected = select_notes(nodes, link_lookup, target_count=1000)
|
||||
|
||||
print("\n🔍 Analyzing broken links...")
|
||||
|
||||
# Count links by type
|
||||
total_outlinks = 0
|
||||
resolved = 0
|
||||
unresolved_but_exists = 0
|
||||
unresolved_not_exists = 0
|
||||
|
||||
for path in selected:
|
||||
node = nodes[path]
|
||||
for link_ref in node['outlinks']:
|
||||
total_outlinks += 1
|
||||
|
||||
if link_ref in link_lookup:
|
||||
target = link_lookup[link_ref]
|
||||
if target in selected:
|
||||
resolved += 1
|
||||
else:
|
||||
# Exists but not in selection
|
||||
unresolved_but_exists += 1
|
||||
else:
|
||||
# Doesn't exist at all
|
||||
unresolved_not_exists += 1
|
||||
|
||||
print(f"Total outlinks: {total_outlinks}")
|
||||
print(f" Resolved (in selection): {resolved} ({resolved/total_outlinks*100:.1f}%)")
|
||||
print(f" Unresolved but note exists: {unresolved_but_exists} ({unresolved_but_exists/total_outlinks*100:.1f}%)")
|
||||
print(f" Unresolved - note doesn't exist: {unresolved_not_exists} ({unresolved_not_exists/total_outlinks*100:.1f}%)")
|
||||
|
||||
print(f"\nIf we include ALL existing notes (not just selected):")
|
||||
print(f" Max possible resolution: {(resolved + unresolved_but_exists)/total_outlinks*100:.1f}%")
|
||||
7
demo-vault-v2/area/refactoring.md
Normal file
7
demo-vault-v2/area/refactoring.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
Is A: Area
|
||||
Status: Active
|
||||
---
|
||||
# Refactoring
|
||||
|
||||
Area note covering refactoring practices, principles, and techniques for improving code quality without changing external behavior.
|
||||
6
demo-vault-v2/note/refactoring-ideas.md
Normal file
6
demo-vault-v2/note/refactoring-ideas.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
Is A: Note
|
||||
---
|
||||
# Refactoring Ideas
|
||||
|
||||
Collection of ideas for refactoring the codebase to improve maintainability and performance.
|
||||
6
demo-vault-v2/note/refactoring-key-ideas.md
Normal file
6
demo-vault-v2/note/refactoring-key-ideas.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
Is A: Note
|
||||
---
|
||||
# Refactoring Key Ideas
|
||||
|
||||
Key takeaways from Martin Fowler's Refactoring book and other refactoring resources.
|
||||
6
demo-vault-v2/note/refactoring-patterns.md
Normal file
6
demo-vault-v2/note/refactoring-patterns.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
Is A: Note
|
||||
---
|
||||
# Refactoring Patterns
|
||||
|
||||
Common refactoring patterns including Extract Method, Rename Variable, and Replace Conditional with Polymorphism.
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
Key abstractions and domain models in Laputa.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Laputa's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
|
||||
|
||||
The full set of design principles is documented in [ARCHITECTURE.md](./ARCHITECTURE.md#design-principles).
|
||||
|
||||
## Semantic Field Names (conventions)
|
||||
|
||||
These frontmatter field names have special meaning in Laputa's UI:
|
||||
|
||||
| Field | Meaning | UI behavior |
|
||||
|---|---|---|
|
||||
| `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping |
|
||||
| `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header |
|
||||
| `url:` | External link | Clickable link chip in editor header |
|
||||
| `date:` | Single date | Formatted date badge |
|
||||
| `start_date:` + `end_date:` | Duration/timespan | Date range badge |
|
||||
| `goal:` + `result:` | Progress | Progress indicator in editor header |
|
||||
| `Workspace:` | Vault context filter | Global workspace filter |
|
||||
| `Belongs to:` | Parent relationship | Relationship chip in Properties panel |
|
||||
| `Related to:` | Lateral relationship | Relationship chip in Properties panel |
|
||||
|
||||
The list of default-shown relationships and semantic property rendering rules can be customized via `config/relations.md` and `config/semantic-properties.md` in the vault.
|
||||
|
||||
## Document Model
|
||||
|
||||
All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth.
|
||||
@@ -16,7 +40,7 @@ interface VaultEntry {
|
||||
path: string // Absolute file path
|
||||
filename: string // Just the filename
|
||||
title: string // From first # heading, or filename fallback
|
||||
isA: string | null // Entity type: Project, Procedure, Person, etc.
|
||||
isA: string | null // Entity type: Project, Procedure, Person, etc. (from frontmatter `type:` field)
|
||||
aliases: string[] // Alternative names for wikilink resolution
|
||||
belongsTo: string[] // Parent relationships (wikilinks)
|
||||
relatedTo: string[] // Related entity links (wikilinks)
|
||||
@@ -37,9 +61,11 @@ interface VaultEntry {
|
||||
}
|
||||
```
|
||||
|
||||
### Entity Types (isA)
|
||||
### Entity Types (isA / type)
|
||||
|
||||
Entity type is inferred from the folder structure. The vault is organized by type:
|
||||
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
|
||||
|
||||
Type is also inferred from the folder structure when `type:` is absent. The vault is organized by type:
|
||||
|
||||
```
|
||||
~/Laputa/
|
||||
@@ -66,7 +92,7 @@ Mapping logic lives in `vault/mod.rs:parse_md_file()`. If a folder doesn't match
|
||||
|
||||
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
|
||||
|
||||
- Have `Is A: Type` in their frontmatter
|
||||
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
|
||||
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
|
||||
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
|
||||
- Serve as the "definition" for their type category
|
||||
@@ -171,7 +197,7 @@ type SidebarSelection =
|
||||
- Reads content with `fs::read_to_string()`
|
||||
- Parses frontmatter with `gray_matter::Matter::<YAML>`
|
||||
- Extracts title from first `#` heading
|
||||
- Infers entity type from parent folder name (or explicit `Is A`/`type` frontmatter)
|
||||
- Infers entity type from parent folder name (or explicit `type:` frontmatter; `Is A:` accepted as legacy alias)
|
||||
- Parses dates as ISO 8601 to Unix timestamps
|
||||
- Extracts relationships, outgoing links, custom properties, word count, snippet
|
||||
5. Sorts by `modified_at` descending
|
||||
@@ -181,12 +207,13 @@ type SidebarSelection =
|
||||
|
||||
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
|
||||
|
||||
1. Reads `.laputa-cache.json` if it exists
|
||||
1. Reads cache from `~/.laputa/cache/<vault-hash>.json` (external to vault)
|
||||
2. Compares cache version, vault path, and git HEAD commit hash
|
||||
3. If cache is valid and same commit → only re-parse uncommitted changed files
|
||||
4. If different commit → use `git diff` to find changed files → selective re-parse
|
||||
5. If no cache → full scan
|
||||
6. Writes updated cache after every scan
|
||||
6. Writes updated cache atomically (write to `.tmp`, then rename)
|
||||
7. On first run, migrates any legacy `.laputa-cache.json` from inside the vault
|
||||
|
||||
### Frontmatter Manipulation (Rust)
|
||||
|
||||
@@ -334,11 +361,11 @@ Two-layer theming:
|
||||
|
||||
### Vault-Based Themes
|
||||
|
||||
Themes are markdown notes in `theme/` with `Is A: Theme` frontmatter. Each property becomes a CSS variable with `--` prefix.
|
||||
Themes are markdown notes in `theme/` with `type: Theme` frontmatter. Each property becomes a CSS variable with `--` prefix.
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Theme
|
||||
type: Theme
|
||||
Description: Light theme with warm, paper-like tones
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
@@ -381,7 +408,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
|
||||
- Keys in `SKIP_KEYS` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
|
||||
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, and all custom relationship fields as clickable wikilink chips.
|
||||
|
||||
|
||||
@@ -2,6 +2,51 @@
|
||||
|
||||
Laputa is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
|
||||
|
||||
## Design Principles
|
||||
|
||||
### Filesystem as the single source of truth
|
||||
|
||||
The vault is a folder of plain markdown files. The app never owns the data — it only reads and writes files. The cache, React state, and any in-memory representation are always derived from the filesystem and must be reconstructible by deleting them. When in doubt, the file on disk wins.
|
||||
|
||||
### Convention over configuration
|
||||
|
||||
Laputa is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `Belongs to:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
|
||||
|
||||
This principle directly serves AI-readability: the more structure comes from shared conventions rather than per-user custom configurations, the easier it is for an AI agent to understand and navigate the vault correctly — without needing bespoke instructions for every setup.
|
||||
|
||||
### No hardcoded exceptions
|
||||
|
||||
No field names, folder paths, or vault-specific values should be hardcoded in the application source code. What can be a convention should be a convention. What needs to be configurable should live in a file. Relationship fields are detected dynamically by checking whether values contain `[[wikilinks]]` — no hardcoded field name lists.
|
||||
|
||||
### AI-first knowledge graph
|
||||
|
||||
Notes are not just documents — they are nodes in a structured graph of people, projects, events, responsibilities, and ideas. Every design decision should ask: "Does this make the knowledge graph easier for a human *and* an AI to navigate?" Conventions that are legible to both are better than conventions that are legible only to one.
|
||||
|
||||
### Three representations, one authority
|
||||
|
||||
Vault data exists in three forms simultaneously:
|
||||
1. **Filesystem** — the `.md` files on disk. This is the single source of truth.
|
||||
2. **Cache** — `~/.laputa/cache/<hash>.json`, an index for fast startup. Always reconstructible from the filesystem.
|
||||
3. **React state** — the in-memory `VaultEntry[]` during a session. Always derived from the cache or filesystem.
|
||||
|
||||
These must never diverge permanently. If they do, the filesystem wins and the cache/state are rebuilt.
|
||||
|
||||
#### Ownership rules
|
||||
|
||||
| Layer | Owner | Writes to | Reads from |
|
||||
|-------|-------|-----------|------------|
|
||||
| Filesystem | Tauri Rust commands (`save_note_content`, `update_frontmatter`, etc.) | Disk | — |
|
||||
| Cache | `scan_vault_cached()` in `vault/cache.rs` | `~/.laputa/cache/` | Filesystem + git diff |
|
||||
| React state | `useVaultLoader` + `useEntryActions` + `useNoteActions` | In-memory `entries` | Cache (on load), filesystem (on reload) |
|
||||
|
||||
#### Invariants
|
||||
|
||||
1. **Disk-first writes**: All functions that change vault data must write to disk (via Tauri IPC) *before* updating React state. This ensures that if the disk write fails, React state remains consistent with what's actually on disk.
|
||||
2. **Optimistic UI with rollback**: Where responsiveness matters (e.g. `persistOptimistic` in `useNoteActions`), state may update before disk confirmation — but a failure callback must revert the optimistic state.
|
||||
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
|
||||
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
|
||||
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology | Version |
|
||||
@@ -50,7 +95,7 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
|
||||
│ Tauri IPC│ Vite Proxy / WS │
|
||||
│ ┌──────────────▼────┐ ┌──▼────────────────────────────────┐ │
|
||||
│ │ Rust Backend │ │ External Services │ │
|
||||
│ │ lib.rs → 61 cmds │ │ Anthropic API (Claude chat) │ │
|
||||
│ │ lib.rs → 62 cmds │ │ Anthropic API (Claude chat) │ │
|
||||
│ │ vault/ │ │ Claude CLI (agent subprocess) │ │
|
||||
│ │ frontmatter/ │ │ MCP Server (ws://9710, 9711) │ │
|
||||
│ │ git/ │ │ qmd (search/indexing engine) │ │
|
||||
@@ -89,7 +134,7 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Tab bar with modified dots, breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`.
|
||||
- **Inspector / AI Chat / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history), AI Chat panel (API-based), and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them.
|
||||
- **Inspector / AI Chat / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history), AI Chat panel (API-based), and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
@@ -176,7 +221,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
|------|--------|-------------|
|
||||
| `open_note` | `path` | Open and read a note by relative path |
|
||||
| `read_note` | `path` | Read note content (alias for `open_note`) |
|
||||
| `create_note` | `path, title, [is_a]` | Create new note with title and optional type frontmatter |
|
||||
| `create_note` | `path, title, [type]` | Create new note with title and optional type frontmatter |
|
||||
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
|
||||
| `append_to_note` | `path, text` | Append text to end of existing note |
|
||||
| `edit_note_frontmatter` | `path, patch` | Merge key-value patch into YAML frontmatter |
|
||||
@@ -300,7 +345,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
|
||||
|
||||
### Cache File
|
||||
|
||||
`.laputa-cache.json` at vault root. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan).
|
||||
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan). Writes are atomic (write to `.tmp` then rename). Legacy `.laputa-cache.json` files inside the vault are auto-migrated and deleted on first run.
|
||||
|
||||
### Three Cache Strategies
|
||||
|
||||
@@ -308,8 +353,6 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
|
||||
2. **Different Commit (Incremental Update)**: Uses `git diff <old>..<new> --name-only` to find changed files + uncommitted changes → selective re-parse
|
||||
3. **No Cache / Corrupt Cache (Full Scan)**: Recursive `walkdir` of all `.md` files → full parse
|
||||
|
||||
Cache auto-excludes itself from git via `.git/info/exclude`.
|
||||
|
||||
## Theme System
|
||||
|
||||
See [THEMING.md](./THEMING.md) for the full theme system documentation.
|
||||
@@ -321,7 +364,7 @@ See [THEMING.md](./THEMING.md) for the full theme system documentation.
|
||||
|
||||
### Vault-Based Themes
|
||||
|
||||
Themes are markdown notes in the `theme/` folder with `Is A: Theme` frontmatter. Each frontmatter property becomes a CSS variable. Managed by `useThemeManager` hook and the `src-tauri/src/theme/` Rust module (create, seed, defaults).
|
||||
Themes are markdown notes in the `theme/` folder with `type: Theme` frontmatter (`Is A: Theme` accepted as legacy alias). Each frontmatter property becomes a CSS variable. Managed by `useThemeManager` hook and the `src-tauri/src/theme/` Rust module (create, seed, defaults).
|
||||
|
||||
- **Vault settings**: `.laputa/settings.json` stores the active theme reference
|
||||
- **Legacy support**: `_themes/*.json` files still supported for backward compatibility
|
||||
@@ -471,13 +514,13 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
|
||||
| `ai_chat.rs` | Direct Anthropic API client (non-streaming, for Tauri builds) |
|
||||
| `mcp.rs` | MCP server spawning + config registration |
|
||||
| `commands.rs` | All 61 Tauri command handlers |
|
||||
| `commands.rs` | All 62 Tauri command handlers |
|
||||
| `settings.rs` | App settings persistence |
|
||||
| `vault_config.rs` | Per-vault UI config |
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
|
||||
## Tauri IPC Commands (61 total)
|
||||
## Tauri IPC Commands (62 total)
|
||||
|
||||
### Vault Operations
|
||||
|
||||
@@ -491,6 +534,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `batch_archive_notes` | Archive multiple notes |
|
||||
| `batch_trash_notes` | Trash multiple notes |
|
||||
| `purge_trash` | Delete notes trashed >30 days ago |
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_getting_started_vault` | Bootstrap demo vault |
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
### Add a new entity type
|
||||
|
||||
1. Create the folder in the vault (e.g., `~/Laputa/mytype/`)
|
||||
2. Create a type document: `type/mytype.md` with `Is A: Type` frontmatter (icon, color, order, etc.)
|
||||
2. Create a type document: `type/mytype.md` with `type: Type` frontmatter (icon, color, order, etc.)
|
||||
3. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true`
|
||||
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog
|
||||
|
||||
@@ -344,7 +344,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
### Add or modify a theme
|
||||
|
||||
1. **Vault-based** (preferred): Create/edit a markdown note in `theme/` with `Is A: Theme` frontmatter
|
||||
1. **Vault-based** (preferred): Create/edit a markdown note in `theme/` with `type: Theme` frontmatter
|
||||
2. **Programmatic**: Edit defaults in `src-tauri/src/theme/defaults.rs`
|
||||
3. See `docs/THEMING.md` for the full property reference
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ M1 passed all tests but showed 0 notes — vault path wrong, error silently swal
|
||||
- [x] Tauri v2 + React 19 + TypeScript + Vite 7 project setup
|
||||
- [x] Configure Vitest (7 tests), Playwright (2 E2E tests), Rust tests (10 tests)
|
||||
- [x] Rust backend: `list_vault` command — scans directory, parses YAML frontmatter via `gray_matter` crate
|
||||
- [x] Rust backend: extracts Is A, aliases, Belongs to, Related to, Status, Owner, Cadence, title from H1
|
||||
- [x] Rust backend: extracts type (Is A), aliases, Belongs to, Related to, Status, Owner, Cadence, title from H1
|
||||
- [x] React: four-panel layout (Sidebar 250px, NoteList 300px, Editor flex, Inspector 280px), all resizable
|
||||
- [x] Tauri mock layer for browser testing (`src/mock-tauri.ts`)
|
||||
- [x] Screenshot verification via Playwright (`e2e/screenshot.spec.ts`)
|
||||
@@ -281,7 +281,7 @@ bc75647 Remove unused Vite scaffold files
|
||||
### M2: Sidebar & Note List
|
||||
**Goal:** Navigate the vault via sidebar, see filtered note lists.
|
||||
- [ ] Sidebar: Filters section (All Notes, Favorites, Trash)
|
||||
- [ ] Sidebar: Section Groups (Projects, Experiments, Responsibilities, Procedures) — populated from frontmatter `Is A`
|
||||
- [ ] Sidebar: Section Groups (Projects, Experiments, Responsibilities, Procedures) — populated from frontmatter `type:`
|
||||
- [ ] Sidebar: Topics — flat list, populated from `Related to` topic links
|
||||
- [ ] Note list: show title, preview snippet, date, type indicator
|
||||
- [ ] Note list: sort by last modified (descending)
|
||||
@@ -313,7 +313,7 @@ bc75647 Remove unused Vite scaffold files
|
||||
|
||||
### M5: File Operations & Polish
|
||||
**Goal:** Create, rename, delete files. Polish for daily-driver use.
|
||||
- [ ] Create new note (with type selector → sets `Is A` and target folder)
|
||||
- [ ] Create new note (with type selector → sets `type:` and target folder)
|
||||
- [ ] Rename file (updates filename + title)
|
||||
- [ ] Delete → move to trash
|
||||
- [ ] Keyboard shortcuts (Cmd+N new, Cmd+S save, Cmd+P quick open/search)
|
||||
|
||||
108
docs/ROADMAP.md
Normal file
108
docs/ROADMAP.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Laputa — Product Roadmap
|
||||
|
||||
*Strategic directions, not implementation tasks. Each item here represents a direction that will be broken down into many smaller tasks when the time comes.*
|
||||
|
||||
*Updated: March 2026.*
|
||||
|
||||
---
|
||||
|
||||
## Consolidation sprint (current priority)
|
||||
|
||||
Before building new features, the architectural foundations must be solid. Key structural fixes underway:
|
||||
|
||||
- Move vault cache outside the vault directory (→ `~/.laputa/cache/`) with atomic writes
|
||||
- Flip `type:` to canonical field in Rust parser (`Is A:` becomes alias)
|
||||
- Remove `allContent` from the architecture — derive backlinks from open tabs only
|
||||
- ~~Remove hardcoded `RELATIONSHIP_KEYS` — detect wikilink fields dynamically~~ ✅ Done
|
||||
- Fix hardcoded vault path in `resolveNewNote` / `resolveNewType` / `resolveDailyNote`
|
||||
- Define and enforce the three-source-of-truth contract (filesystem → cache → React state)
|
||||
|
||||
These are not features — they are the foundation everything else is built on.
|
||||
|
||||
---
|
||||
|
||||
## Strategic directions
|
||||
|
||||
### 1. Semantic properties
|
||||
|
||||
**What:** Conventional frontmatter field names (`status:`, `url:`, `start_date:`, `end_date:`, `goal:`, `result:`) trigger rich UI rendering beyond the Properties panel — chips in the note list, progress indicators in the editor header, date range badges.
|
||||
|
||||
**Why:** Notes are not just documents. A Project has a start and end. A Responsibility has KPIs. A Procedure has an owner and a cadence. The app should surface this structure visually, not just store it as plain text.
|
||||
|
||||
**Convention over configuration:** the rendering rules ship as sensible defaults. Users can override via `config/semantic-properties.md` in the vault — a plain markdown file, editable from within the app.
|
||||
|
||||
**Draft tasks:** created in Todoist. To be prioritized after consolidation sprint.
|
||||
|
||||
---
|
||||
|
||||
### 2. Default relationships in Properties panel
|
||||
|
||||
**What:** The Properties panel shows a set of relationship fields by default — even when empty — guiding the user toward a connected knowledge graph. Defaults include: Belongs to, Related to, Events, People (Type is already shown).
|
||||
|
||||
**Why:** A new note starts with a completely empty Properties panel today. There's no guidance on how to connect it. Laputa is opinionated — it should show you the connections that matter.
|
||||
|
||||
**Convention over configuration:** the default list is built in, but can be overridden via `config/relations.md` in the vault.
|
||||
|
||||
**Draft tasks:** created in Todoist. Needs design discussion (per-type overrides?) before implementation.
|
||||
|
||||
---
|
||||
|
||||
### 3. Global workspace filter
|
||||
|
||||
**What:** A top-level workspace switcher (below the traffic lights) that filters the entire app — sidebar, note list, search — to show only notes belonging to the selected workspace, plus shared notes (those without a Workspace field).
|
||||
|
||||
**Why:** A single vault often contains both personal and work content. A workspace filter lets you focus on one context at a time without cognitive overhead.
|
||||
|
||||
**How:** Notes opt into a workspace via `Workspace: [[workspace/refactoring]]` frontmatter. Workspace notes are auto-detected from the `workspace/` folder. No setup required.
|
||||
|
||||
**Future trajectory:** Workspaces are the seed of a multi-vault, multi-user access control model. In the future, workspaces may map to separate Git repositories — each with their own access permissions. Different people see different workspaces (vaults). Git provides the audit trail. This enables Laputa to grow from a personal tool to a small-team knowledge base without rebuilding the product.
|
||||
|
||||
**Draft tasks:** created in Todoist. Lower priority than semantic properties and default relationships.
|
||||
|
||||
---
|
||||
|
||||
### 4. Inbox and capture pipeline
|
||||
|
||||
**What:** An Inbox section that surfaces all unorganized notes — those with no outgoing relationships. Replaces "All Notes" as the primary landing section. Capture integrations (Chrome extension, iPhone share sheet, Readwise sync) feed into the inbox automatically.
|
||||
|
||||
**Why:** Capture and organize are fundamentally different activities and should be treated separately. Today Laputa has no concept of an unorganized note — everything lands in the same pool. The inbox makes the unorganized state visible and actionable, creating a discipline: Inbox Zero, reached weekly.
|
||||
|
||||
**The inbox as a smart filter:** not a folder. Any note without `Belongs to:`, `Related to:`, or other meaningful relationship is automatically in the inbox. Connecting a note to something removes it from the inbox, automatically.
|
||||
|
||||
**Capture integrations (future, each a separate feature):**
|
||||
- Chrome extension → saves URL/clip as a note to the vault via Git
|
||||
- iPhone share sheet → quick capture from any app
|
||||
- Readwise / Kindle highlights → synced via Git automation
|
||||
- Voice memo → transcribed and dropped into inbox
|
||||
|
||||
**Priority:** The Inbox UI is high-value and can be implemented without the capture integrations. Integrations come after.
|
||||
|
||||
---
|
||||
|
||||
### 5. Mobile apps
|
||||
|
||||
**What:** Native apps for iPhone and iPad — not ports of the desktop app, but purpose-built for each form factor.
|
||||
|
||||
**iPhone:** Optimized for fast capture. Quick note creation, voice memos, brief thoughts. The primary use case is getting something into the vault quickly while away from the desk. Minimal reading and editing.
|
||||
|
||||
**iPad:** A more capable mirror of the desktop experience — reading, editing, navigating the vault. Not a full four-panel layout, but enough to work on notes meaningfully. Think "laptop replacement for light work sessions."
|
||||
|
||||
**Why it matters:** Laputa's value as a personal knowledge system depends on being able to capture things wherever you are. Without mobile capture, important notes get lost or end up scattered in other apps.
|
||||
|
||||
**Sync:** Git-based, same as desktop. The vault is a Git repo — mobile apps commit and pull like any other client.
|
||||
|
||||
**Priority:** After the desktop experience is solid. Not before.
|
||||
|
||||
---
|
||||
|
||||
## Principles for this roadmap
|
||||
|
||||
- **Foundations before features** — a shaky architecture multiplies the cost of every feature built on top of it
|
||||
- **Convention over configuration** — ship strong defaults, allow customization via vault files
|
||||
- **File-first** — every strategic direction must be achievable without breaking the markdown-files-on-disk model
|
||||
- **AI-readable by design** — conventions that humans find intuitive should also be legible to AI agents navigating the vault
|
||||
|
||||
---
|
||||
|
||||
*For active tasks and bugs, see the Todoist board (Laputa App project).*
|
||||
*For architectural decisions and design principles, see [ARCHITECTURE.md](./ARCHITECTURE.md) and [VISION.md](./VISION.md).*
|
||||
289
docs/VISION.md
289
docs/VISION.md
@@ -1,156 +1,223 @@
|
||||
# Laputa — Product Vision
|
||||
|
||||
*Written by Brian based on conversations with Luca Rossi, Feb 2026.*
|
||||
*Written by Brian based on conversations with Luca Rossi, Feb–Mar 2026.*
|
||||
*This is a living document — update it as the vision evolves.*
|
||||
|
||||
---
|
||||
|
||||
## Why Laputa exists
|
||||
## Why this, why now, why us
|
||||
|
||||
Luca has been doing personal knowledge management since university — long before it had a name. Over the years, through two startups, becoming a CTO, and eventually building Refactoring (a newsletter publishing 2-3 articles/week), note-taking evolved from a nice-to-have to a core part of his work. The ability to synthesize ideas, connect concepts across time, and turn accumulated knowledge into articles reliably every week — this only works with a well-structured system.
|
||||
Before the what and how: the why.
|
||||
|
||||
For years, that system lived in Notion. But over time, the overlap between what Notion offers and what Luca actually needs started to shrink. Notion became simultaneously too narrow (missing specific things he needed) and too wide (full of flexibility he didn't want). The gap became impossible to ignore when AI entered the picture.
|
||||
The best projects are built by people who have an unusually strong answer to "why are you the right person to build this?" This is that answer.
|
||||
|
||||
## The core insight: local files + Git = AI-native PKM
|
||||
**Luca Rossi** is a startup founder and former generalist CTO — someone who can build a product end-to-end across code, design, scope, and product. And for the last five years, full-time, he has run Refactoring: a technical newsletter with nearly 200,000 subscribers, for which he has written over 300 original articles. In word count, that's roughly two *Lord of the Rings* novels.
|
||||
|
||||
The fundamental insight behind Laputa is architectural: **a knowledge base made of local Markdown files, version-controlled with Git, is orders of magnitude more AI-friendly than any SaaS-based system.**
|
||||
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Laputa is an attempt to bottle that system.
|
||||
|
||||
Notion's AI struggles with complex workspaces — slow, inaccurate, often failing to understand its own structure. Meanwhile, an AI like Claude or Claude Code working on a local vault of Markdown files can read, edit, and reorganize thousands of documents in seconds, with full comprehension.
|
||||
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Laputa is not theorized — it's been battle-tested for years at scale.
|
||||
|
||||
This isn't a feature — it's a structural advantage that no Notion redesign can fix. The architecture *is* the product.
|
||||
**The distribution is built in.** Refactoring reaches ~200,000 engineers, managers, and technical leaders — exactly the people most receptive to a tool like this. The audience already trusts the author on this topic, because they've been reading his writing about knowledge management and learning for years.
|
||||
|
||||
Additional benefits that fall out of this choice for free:
|
||||
- **Version control**: every change is tracked, diffable, reversible
|
||||
- **Open format**: your knowledge is yours, readable by any tool, forever
|
||||
- **Remote AI access**: an AI agent can commit to your Git repo from anywhere — your knowledge base becomes programmable
|
||||
- **Zero lock-in**: if something better comes along, you leave. The trust between Laputa and the user is earned daily, not enforced by proprietary formats
|
||||
This is not a product looking for a market. It's a tool built by its first power user, for an audience that already knows and trusts him.
|
||||
|
||||
## Why not just use Obsidian?
|
||||
**Why Laputa, in the context of Refactoring.**
|
||||
|
||||
Refactoring is a newsletter about how software is built, how teams work, and how digital products are developed — written from Luca's experience and conversations with other tech leaders. A natural question follows: what is the author's own current experience building software with AI?
|
||||
|
||||
Laputa answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
|
||||
|
||||
This is why Laputa is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
|
||||
|
||||
The strategy is coherent: build the tool you describe, make the work visible, let the product speak for the author.
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
Most people who want to work effectively with AI face a version of the same problem: **they don't have their knowledge organized in a way that AI can actually use.**
|
||||
|
||||
They have notes scattered across Notion, Apple Notes, browser bookmarks, and email. Some of it is structured, most of it isn't. Even the people who do maintain a knowledge base discover that AI tools — ChatGPT, Notion AI, others — struggle to navigate it meaningfully. The knowledge is there, but it's not *accessible*.
|
||||
|
||||
The problem has two distinct layers:
|
||||
|
||||
1. **Architectural**: most knowledge tools store data in proprietary formats on remote servers. AI tools can't read them efficiently, can't commit changes back, can't reason over the full structure. The format itself creates a ceiling.
|
||||
|
||||
2. **Methodological**: even with the right tool, most people don't know *how* to organize knowledge so it becomes useful over time — what to capture, how to connect things, how to turn raw notes into a system that works with you instead of against you.
|
||||
|
||||
Laputa addresses both layers, together. That's what makes it different.
|
||||
|
||||
---
|
||||
|
||||
## The insight: tool and method, together
|
||||
|
||||
Most PKM tools give you a blank canvas and leave the rest to you. They solve the first problem (somewhere to put things) but not the second (how to organize them). The result is that sophisticated users build complex custom systems, while everyone else gives up.
|
||||
|
||||
Laputa's position is different: **we ship the method alongside the tool.**
|
||||
|
||||
The method is opinionated but not rigid. It tells you: here's how to think about your work, here's where different kinds of notes belong, here's how to connect them. If it fits your needs — great, start immediately. If your situation is different — customize it. The types, the relationships, the structure can all be changed. But you don't have to figure it out from scratch.
|
||||
|
||||
This combination — an opinionated method on top of a technically excellent foundation — is what makes Laputa genuinely useful to people who are stuck, not just people who already know what they're doing.
|
||||
|
||||
---
|
||||
|
||||
## The method: a framework for knowledge work
|
||||
|
||||
### The knowledge ontology
|
||||
|
||||
Laputa organizes work around two axes:
|
||||
|
||||
| | **One-time** | **Recurring** |
|
||||
|---|---|---|
|
||||
| **Multi-session** | **Project** (has a start and end) | **Responsibility** (no end, measured by KPIs) |
|
||||
| **Single-session** | *Task* (lives in a task manager) | **Procedure** (checklist, routine) |
|
||||
|
||||
Everything else is context:
|
||||
- **Notes** — the atomic unit. Any note connects to one or more of the above.
|
||||
- **Topics** — areas of interest with no performance expectation. A knowledge repository.
|
||||
- **Events** — things that happened, anchored to a date.
|
||||
- **People** — contacts and their history.
|
||||
|
||||
Relations between notes are first-class citizens — not just wiki-links, but typed, bidirectional connections that make the knowledge graph navigable.
|
||||
|
||||
This ontology is not arbitrary. It maps cleanly to how both individuals and organizations actually structure their work: companies have projects, responsibilities, procedures, and people. So do independent creators. So do individuals managing their lives.
|
||||
|
||||
### Knowledge has a purpose
|
||||
|
||||
A principle that underlies everything in Laputa: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
|
||||
|
||||
This is the difference between a knowledge system that works over years and one that collapses after a few weeks. Without a real purpose, the maintenance cost of taking notes is never justified, and people stop. With a purpose — writing regularly, building things, making decisions — the system pays for itself.
|
||||
|
||||
What you *do* with organized knowledge depends on who you are:
|
||||
|
||||
- **Writers and content creators** — the output is articles, essays, posts. Captures become highlights, highlights become **evergreen notes** (small, atomic, timeless ideas), evergreen notes become building blocks for articles. Evergreen notes are a middle layer: not the raw input, not the final output, but the refined reusable units that make writing easier and faster.
|
||||
- **Builders and project-driven people** — the output is shipped work. Captures feed projects, decisions, and procedures. Evergreen notes matter less; the project knowledge graph matters more.
|
||||
- **Operators and managers** — the output is better systems and decisions. Captures feed responsibilities (KPIs, workflows) and procedures (how we do things). The value accumulates in the recurring structure, not in individual notes.
|
||||
|
||||
The framework is flexible enough to fit all three — and most people are a mix. What stays constant is the flow: **capture → organize → express**. The *what* of expressing changes; the discipline doesn't.
|
||||
|
||||
### The two-phase workflow: capture and organize
|
||||
|
||||
Notes move through two distinct phases, and the transition between them is intentional.
|
||||
|
||||
**Capture** — fast, frictionless, available everywhere. A thought, a saved article, a Kindle highlight, a voice memo. The cardinal rule: never let friction during capture cause a good idea to be lost. Captured notes land in the vault unconnected — no relationships, no organization. That's fine. That's the point.
|
||||
|
||||
**Organize** — a deliberate, periodic activity (weekly is the natural cadence). You ask: *what is this useful for?* Many things that seemed important when captured won't survive this question — deleting >50% of captures is normal and healthy. For the things that survive: connect them. Link to a Project, a Responsibility, a Topic. Every note should eventually connect to at least one actionable container. If you can't connect something to anything, that's a signal worth paying attention to.
|
||||
|
||||
**The Inbox** is the UI expression of this split: a smart section that shows all unorganized notes — those with no outgoing relationships. The goal is Inbox Zero, reached periodically (weekly). The inbox is not a folder; it's a derived state. Connecting a note to something removes it automatically.
|
||||
|
||||
### Convention over configuration
|
||||
|
||||
The method lives in the app as *conventions*: standard field names and folder structures that have well-defined meanings and trigger specific behavior.
|
||||
|
||||
`status:` shows a colored chip. `Workspace: [[workspace/refactoring]]` assigns a note to a context. `Belongs to:` connects it to its parent. `start_date:` and `end_date:` show a duration badge. The app recognizes these by convention, without any setup.
|
||||
|
||||
Users who want more can override the defaults: `config/relations.md` changes which relationship fields appear by default; `config/semantic-properties.md` controls how fields are rendered. But the defaults work immediately, for everyone.
|
||||
|
||||
This is convention *over* configuration — not convention *instead of* it.
|
||||
|
||||
---
|
||||
|
||||
## The foundation: architecture that earns trust
|
||||
|
||||
The method is only as good as the system it runs on. Laputa's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
|
||||
|
||||
### Local files, version-controlled with Git
|
||||
|
||||
Every note is a plain Markdown file on your disk. There is no database, no proprietary format, no sync lock-in. The files are readable by any tool that can open a text file — today and in twenty years.
|
||||
|
||||
Git provides version control: every change is tracked, diffable, reversible. You have a full audit trail of what changed, when, and why. Collaboration happens via Git — the same way software teams have collaborated for decades, without any proprietary cloud in between.
|
||||
|
||||
### AI-native by design
|
||||
|
||||
A vault of plain Markdown files, version-controlled with Git, is dramatically more AI-friendly than any SaaS-based system.
|
||||
|
||||
An AI agent working on a local vault can read thousands of notes in seconds, understand their structure, write new ones, connect existing ones, and commit the changes back — all with full comprehension. Notion's AI can't do this. No SaaS-based AI can do this, because the architecture doesn't allow it.
|
||||
|
||||
More importantly: the more a vault follows Laputa's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
|
||||
|
||||
### Open and exit-friendly
|
||||
|
||||
The trust between Laputa and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
|
||||
|
||||
---
|
||||
|
||||
## Why not Obsidian?
|
||||
|
||||
Obsidian is the obvious comparison. The difference is philosophy:
|
||||
|
||||
- **Obsidian** is a blank canvas. Infinitely configurable via plugins, themes, and community extensions. Great for power users who want to build their own system from scratch.
|
||||
- **Laputa** is opinionated. It ships with a point of view on how to organize knowledge, with sensible defaults that work out of the box — no plugin hunting required.
|
||||
- **Obsidian** is a blank canvas. Infinitely configurable via plugins and community extensions. Powerful for users who want to build their own system — and who have the time and patience to do so.
|
||||
- **Laputa** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
|
||||
|
||||
Obsidian also treats Git as an afterthought (its business model is built around proprietary sync). In Laputa, **Git is a first-class citizen** — the obvious, natural way to sync and collaborate.
|
||||
Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Laputa, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
|
||||
|
||||
## The knowledge ontology
|
||||
---
|
||||
|
||||
Laputa is built around a clear conceptual model, inspired by PARA but adapted to Luca's real-world usage:
|
||||
## Who it's for, and where it's going
|
||||
|
||||
**Two axes:**
|
||||
1. *One-time* vs *recurring*
|
||||
2. *Single-session* vs *multi-session*
|
||||
### Three stages of adoption
|
||||
|
||||
**Four action types:**
|
||||
- **Projects** — one-time, multi-session (have a start and end)
|
||||
- **Responsibilities** — recurring, multi-session (no end, measured by KPIs)
|
||||
- **Tasks** — one-time, single-session (live in Todoist)
|
||||
- **Procedures** — recurring, single-session (checklists, routines)
|
||||
Laputa is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
|
||||
|
||||
**Knowledge containers:**
|
||||
- **Notes** — the atomic unit. Can belong to any of the above.
|
||||
- **Topics** — areas of interest with no performance expectation (like labels/tags). E.g. "front-end engineering", "interior design"
|
||||
- **Events** — things that happened, tied to a date
|
||||
- **People** — contacts, with a log of interactions
|
||||
**Stage 1: Personal PKM + AI context** *(current)*
|
||||
A single person manages their knowledge, life, and work in a local vault. The primary collaborator is AI. The vault gives structure to one person's context, making it legible to an AI that can assist meaningfully across all areas of work and life. The method helps structure the knowledge; the AI helps use it.
|
||||
|
||||
**Relations** between notes are first-class citizens — not just wiki-links, but typed, bidirectional connections.
|
||||
**Stage 2: Independent knowledge workers**
|
||||
Content creators, freelancers, consultants. People with maximum incentive *and* maximum agency to build a real system. They have projects, clients, responsibilities — and they work alone or in very small teams. The same ontology applies: a newsletter creator has editorial projects, a subscriber-growth responsibility, and a publishing procedure. AI collaboration deepens: the AI can see not just personal notes but client commitments, content pipelines, recurring workflows.
|
||||
|
||||
## The deeper mission: AI context scaffolding
|
||||
**Stage 3: Small teams**
|
||||
The ontology scales to organizations. Companies have projects, responsibilities, procedures, and people — the same categories, at a larger scale. The access model changes: different people see different subsets of the vault, via workspace filtering and Git-based access control. Version history gives teams a full audit trail. AI agents become shared collaborators on team knowledge, not just personal assistants.
|
||||
|
||||
Most people today can't effectively share context about their lives with AI. They don't know what to write, how to structure it, or when. The result is that AI assistants — even the best ones — are working with a fraction of the context they need.
|
||||
**What makes this trajectory coherent:** the foundational model — local files, Git-versioned, structured by conventions — doesn't need to be rebuilt at each stage. It extends naturally.
|
||||
|
||||
Laputa's goal is not just to be an efficient place to store things. It's to provide **scaffolding that makes it easy for people to externalize their knowledge in a structured, AI-readable way** — without having to figure out the system themselves.
|
||||
### The right early adopters
|
||||
|
||||
The vision: a person who uses Laputa well has built a second brain that any AI can read, reason over, and contribute to. Not the naive "memory" that ChatGPT builds from chat history — but an intentional, curated, structured representation of their work and life.
|
||||
|
||||
## Target user (v1)
|
||||
|
||||
Developers and technically-minded knowledge workers who:
|
||||
- Are frustrated with Notion's complexity or performance
|
||||
The first users who will get the most from Laputa are technically-minded individuals who:
|
||||
- Are frustrated with Notion's performance, complexity, or lock-in
|
||||
- Understand or are comfortable with Git
|
||||
- Want a system that's AI-native by design, not by bolted-on features
|
||||
- Value owning their data and formats
|
||||
- Value owning their data
|
||||
|
||||
Broader audiences (non-developers) are a future consideration — they'll need more onboarding and scaffolding to get started, but the underlying model is designed to work for anyone.
|
||||
Broader audiences will follow as the onboarding experience matures and the conventions become easier to adopt.
|
||||
|
||||
---
|
||||
|
||||
## Current state
|
||||
|
||||
A living snapshot of what's built vs what's missing. Updated as features ship.
|
||||
A living snapshot of what's built. Updated as features ship.
|
||||
|
||||
### ✅ What's working today
|
||||
### ✅ Working today
|
||||
|
||||
**Core editor & notes**
|
||||
- BlockNote-based editor (block-style, Notion-like) with Markdown files on disk
|
||||
- Cmd+S save with dirty state indicator (orange dot = modified, green dot = new)
|
||||
- Word count (frontmatter excluded)
|
||||
- Rename note by double-clicking tab
|
||||
- Drag & drop images into editor
|
||||
- Wiki-links with `[[` autocomplete (2+ chars, max 20 results, colored by note type)
|
||||
|
||||
**Navigation & layout**
|
||||
- BlockNote editor with Markdown files on disk; Cmd+S save; dirty state indicator
|
||||
- 4-panel layout: sidebar / note list / editor / inspector
|
||||
- Collapsible sidebar and note list (Cmd+1/2/3)
|
||||
- Tabs with drag-to-reorder
|
||||
- Quick open (Cmd+P) by title
|
||||
- Virtual list rendering for NoteList (handles 9000+ notes without lag)
|
||||
- Tabs with drag-to-reorder; quick open (Cmd+P); virtual list (handles 9000+ notes)
|
||||
- `type:` as canonical frontmatter field; inspector with editable properties
|
||||
- Bidirectional relationships; editable relation chips; wikilink autocomplete (`[[`)
|
||||
- Sidebar sections with custom icons and colors
|
||||
- Git integration: commit & push, version history per note, dirty state tracking
|
||||
- Dynamic vault picker; create or clone vaults from GitHub
|
||||
- GitHub OAuth (device flow); AI chat panel; Claude CLI agent panel
|
||||
- Auto-updater; universal macOS binary; CI with coverage gates
|
||||
|
||||
**Properties & types**
|
||||
- Inspector panel with editable vs read-only properties
|
||||
- Change note type from Inspector (picker/dropdown)
|
||||
- Property value text consistent at 12px
|
||||
- URL properties: click to open in browser, underline on hover
|
||||
- Bidirectional relationships (Referenced By panel)
|
||||
- Editable relations: add/remove linked notes
|
||||
- `type:` as canonical key (removed `is a:` property)
|
||||
### 🚧 Ahead (consolidation sprint → features)
|
||||
|
||||
**Sections & customization**
|
||||
- Sidebar sections with custom icons (290 Phosphor icons, searchable) and colors
|
||||
- Changes view: click "N pending" in status bar → filtered list of modified notes
|
||||
**Consolidation sprint (current):**
|
||||
Fixing architectural foundations before building further — cache model, type field canonicalization, allContent removal, hardcoded paths.
|
||||
|
||||
**Git integration**
|
||||
- Commit & push from within the app (saves pending changes first)
|
||||
- Modified files indicator in status bar, NoteList, and TabBar
|
||||
- Git history per note (version history)
|
||||
- Dirty state clears correctly after save/rename
|
||||
**Next features:**
|
||||
Inbox section, semantic properties (status chips, progress indicators), default relationships in properties panel, workspace filter, mobile apps (iPhone for capture, iPad as desktop mirror).
|
||||
|
||||
**Vault management**
|
||||
- Dynamic vault picker (no hardcoded paths)
|
||||
- Create new local vault or clone/create from GitHub repo
|
||||
- GitHub OAuth login (device flow)
|
||||
*For the full roadmap, see [ROADMAP.md](./ROADMAP.md).*
|
||||
|
||||
**Settings & infrastructure**
|
||||
- Settings panel (Cmd+,): AI provider API keys, stored in app_config_dir
|
||||
- In-app auto-updater (Tauri updater + GitHub Releases)
|
||||
- CI: lint, TypeScript, tests (84% frontend coverage, 85%+ Rust), CodeScene ≥9.2
|
||||
- Universal macOS binary, auto-released on every merge to main
|
||||
|
||||
### 🚧 What's missing (Open tasks)
|
||||
|
||||
**Bugs**
|
||||
- Word count still including some frontmatter in edge cases (under investigation)
|
||||
|
||||
**Improvements**
|
||||
- Date picker for date-type properties
|
||||
- Vista Changes: differentiate new vs modified more clearly
|
||||
- Relation editing UX polish
|
||||
|
||||
**Features (prioritized)**
|
||||
- Full-text search with semantic support (qmd integration)
|
||||
- Command palette (Cmd+K) — Raycast-style actions
|
||||
- `mock-tauri.ts` and `App.tsx` refactor (code health)
|
||||
|
||||
**Vision-level features (not started)**
|
||||
- Onboarding / getting started flow with default note types
|
||||
- AI-powered features (search, summarization, linking suggestions)
|
||||
- Graph view
|
||||
- Mobile / web access via Git remote
|
||||
---
|
||||
|
||||
## Design principles
|
||||
|
||||
1. **Opinionated but not rigid** — ship strong defaults, allow customization where it matters
|
||||
2. **Git-first** — sync, history, and collaboration via Git; no proprietary cloud
|
||||
3. **AI-native architecture** — local files, open formats, readable by any AI tool
|
||||
4. **Zero lock-in** — earn trust daily; the exit door is always open
|
||||
5. **Ready out of the box** — no plugin hunting, no theme configuration; it just works
|
||||
6. **Relations as first-class citizens** — connections between notes are as important as the notes themselves
|
||||
1. **Opinionated but not rigid** — ship the method and the defaults; allow customization where it matters
|
||||
2. **Convention over configuration** — standard field names trigger rich behavior automatically; users can override via vault config files
|
||||
3. **Git-first** — sync, history, collaboration, and audit trail via Git; no proprietary cloud
|
||||
4. **AI-native architecture** — local files, open formats, structured by conventions legible to both humans and AI
|
||||
5. **Zero lock-in** — earn trust daily; the exit door is always open
|
||||
6. **Capture and organize are separate** — the inbox makes unorganized notes visible; Inbox Zero is the discipline
|
||||
7. **Relations as first-class citizens** — connections between notes are as important as the notes themselves
|
||||
8. **Filesystem as the single source of truth** — the app never owns the data; cache and UI state are always derived and reconstructible
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineConfig } from '@playwright/test'
|
||||
export default defineConfig({
|
||||
testDir: './tests/smoke',
|
||||
timeout: 15_000,
|
||||
retries: 0,
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:5201',
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
@@ -13,7 +13,7 @@ use crate::indexing::{IndexStatus, IndexingProgress};
|
||||
use crate::search::SearchResponse;
|
||||
use crate::settings::Settings;
|
||||
use crate::theme::{ThemeFile, VaultSettings};
|
||||
use crate::vault::{RenameResult, VaultEntry};
|
||||
use crate::vault::{MoveResult, RenameResult, VaultEntry};
|
||||
use crate::vault_config::VaultConfig;
|
||||
use crate::vault_list::VaultList;
|
||||
use crate::{
|
||||
@@ -90,6 +90,17 @@ pub fn rename_note(
|
||||
vault::rename_note(&vault_path, &old_path, &new_title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn move_note_to_type_folder(
|
||||
vault_path: String,
|
||||
note_path: String,
|
||||
new_type: String,
|
||||
) -> Result<MoveResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let note_path = expand_tilde(¬e_path);
|
||||
vault::move_note_to_type_folder(&vault_path, ¬e_path, &new_type)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -128,6 +139,19 @@ pub fn get_default_vault_path() -> Result<String, String> {
|
||||
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::invalidate_cache(std::path::Path::new(path.as_ref()));
|
||||
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn reload_vault_entry(path: String) -> Result<VaultEntry, String> {
|
||||
let path = expand_tilde(&path);
|
||||
vault::reload_entry(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -660,6 +684,83 @@ mod tests {
|
||||
assert!(content.contains("Trashed at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_vault_entry_reads_from_disk() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let note = dir.path().join("note.md");
|
||||
std::fs::write(¬e, "---\nStatus: Active\n---\n# Test\n").unwrap();
|
||||
|
||||
let entry = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
|
||||
assert_eq!(entry.title, "Test");
|
||||
assert_eq!(entry.status, Some("Active".to_string()));
|
||||
|
||||
// Modify file on disk
|
||||
std::fs::write(¬e, "---\nStatus: Done\n---\n# Test\n").unwrap();
|
||||
let fresh = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
|
||||
assert_eq!(fresh.status, Some("Done".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_vault_entry_nonexistent() {
|
||||
let result = reload_vault_entry("/nonexistent/path.md".to_string());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_vault_invalidates_cache_and_rescans() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
// Init git repo for caching to work
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "t@t.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "T"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Set test cache dir to avoid polluting real cache
|
||||
let cache_dir = tempfile::TempDir::new().unwrap();
|
||||
std::env::set_var(
|
||||
"LAPUTA_CACHE_DIR",
|
||||
cache_dir.path().to_string_lossy().as_ref(),
|
||||
);
|
||||
|
||||
std::fs::write(vault.join("note.md"), "---\nTrashed: false\n---\n# Note\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Prime cache via list_vault
|
||||
let entries = list_vault(vault.to_str().unwrap().to_string()).unwrap();
|
||||
assert!(!entries[0].trashed);
|
||||
|
||||
// Trash the note on disk
|
||||
std::fs::write(vault.join("note.md"), "---\nTrashed: true\n---\n# Note\n").unwrap();
|
||||
|
||||
// reload_vault must return the updated trashed state
|
||||
let fresh = reload_vault(vault.to_str().unwrap().to_string()).unwrap();
|
||||
assert!(
|
||||
fresh[0].trashed,
|
||||
"reload_vault must reflect disk state after trashing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_vault_exists_false() {
|
||||
assert!(!check_vault_exists("/nonexistent/path/abc123".to_string()));
|
||||
|
||||
@@ -44,7 +44,6 @@ pub fn init_repo(path: &str) -> Result<(), String> {
|
||||
std::fs::write(
|
||||
&gitignore_path,
|
||||
"# Laputa app files (machine-specific, never commit)\n\
|
||||
.laputa-cache.json\n\
|
||||
.laputa/settings.json\n\
|
||||
\n\
|
||||
# macOS\n\
|
||||
@@ -265,7 +264,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_creates_gitignore_with_ds_store() {
|
||||
fn test_init_repo_creates_gitignore() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("new-vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
@@ -283,14 +282,15 @@ mod tests {
|
||||
content.contains(".DS_Store"),
|
||||
".gitignore should exclude .DS_Store"
|
||||
);
|
||||
assert!(
|
||||
content.contains(".laputa-cache.json"),
|
||||
".gitignore should exclude .laputa-cache.json"
|
||||
);
|
||||
assert!(
|
||||
content.contains(".laputa/settings.json"),
|
||||
".gitignore should exclude settings.json"
|
||||
);
|
||||
// Cache is now stored outside the vault — no need for .gitignore entry
|
||||
assert!(
|
||||
!content.contains(".laputa-cache.json"),
|
||||
".gitignore should NOT contain .laputa-cache.json (cache is external)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -118,6 +118,7 @@ pub fn run() {
|
||||
commands::update_frontmatter,
|
||||
commands::delete_frontmatter_property,
|
||||
commands::rename_note,
|
||||
commands::move_note_to_type_folder,
|
||||
commands::get_file_history,
|
||||
commands::get_modified_files,
|
||||
commands::get_file_diff,
|
||||
@@ -136,6 +137,8 @@ pub fn run() {
|
||||
commands::check_claude_cli,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_claude_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::purge_trash,
|
||||
|
||||
@@ -49,6 +49,7 @@ const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
|
||||
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
|
||||
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
|
||||
const VAULT_REINDEX: &str = "vault-reindex";
|
||||
const VAULT_RELOAD: &str = "vault-reload";
|
||||
const VAULT_REPAIR: &str = "vault-repair";
|
||||
|
||||
const CUSTOM_IDS: &[&str] = &[
|
||||
@@ -91,6 +92,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
VAULT_REINDEX,
|
||||
VAULT_RELOAD,
|
||||
VAULT_REPAIR,
|
||||
];
|
||||
|
||||
@@ -339,6 +341,9 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
let reindex = MenuItemBuilder::new("Reindex Vault")
|
||||
.id(VAULT_REINDEX)
|
||||
.build(app)?;
|
||||
let reload = MenuItemBuilder::new("Reload Vault")
|
||||
.id(VAULT_RELOAD)
|
||||
.build(app)?;
|
||||
let repair = MenuItemBuilder::new("Repair Vault")
|
||||
.id(VAULT_REPAIR)
|
||||
.build(app)?;
|
||||
@@ -356,6 +361,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
.item(&view_changes)
|
||||
.separator()
|
||||
.item(&reindex)
|
||||
.item(&reload)
|
||||
.item(&repair)
|
||||
.item(&install_mcp)
|
||||
.build()?)
|
||||
@@ -475,6 +481,7 @@ mod tests {
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
VAULT_REINDEX,
|
||||
VAULT_RELOAD,
|
||||
];
|
||||
for id in &expected {
|
||||
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::indexing;
|
||||
use crate::vault;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -160,16 +161,19 @@ pub fn search_vault(
|
||||
|
||||
let results: Vec<SearchResult> = qmd_results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
.filter_map(|r| {
|
||||
let path = qmd_uri_to_vault_path(&r.file, vault_path);
|
||||
if vault::is_file_trashed(Path::new(&path)) {
|
||||
return None;
|
||||
}
|
||||
let snippet = extract_clean_snippet(&r.snippet);
|
||||
SearchResult {
|
||||
Some(SearchResult {
|
||||
title: r.title,
|
||||
path,
|
||||
snippet,
|
||||
score: r.score,
|
||||
note_type: None,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
|
||||
|
||||
/// Vault-based theme note for the built-in Default theme.
|
||||
pub const DEFAULT_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
type: Theme\n\
|
||||
Description: Light theme with warm, paper-like tones\n\
|
||||
background: \"#FFFFFF\"\n\
|
||||
foreground: \"#37352F\"\n\
|
||||
@@ -220,7 +220,7 @@ The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
|
||||
|
||||
/// Vault-based theme note for the built-in Dark theme.
|
||||
pub const DARK_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
type: Theme\n\
|
||||
Description: Dark variant with deep navy tones\n\
|
||||
background: \"#0f0f1a\"\n\
|
||||
foreground: \"#e0e0e0\"\n\
|
||||
@@ -276,7 +276,7 @@ A dark theme with deep navy tones for comfortable night-time reading.\n";
|
||||
|
||||
/// Vault-based theme note for the built-in Minimal theme.
|
||||
pub const MINIMAL_VAULT_THEME: &str = "---\n\
|
||||
Is A: Theme\n\
|
||||
type: Theme\n\
|
||||
Description: High contrast, minimal chrome\n\
|
||||
background: \"#FAFAFA\"\n\
|
||||
foreground: \"#111111\"\n\
|
||||
@@ -332,7 +332,7 @@ High contrast, minimal chrome. Monospace typography throughout.\n";
|
||||
|
||||
/// Type definition for the Theme note type.
|
||||
pub const THEME_TYPE_DEFINITION: &str = "---\n\
|
||||
Is A: Type\n\
|
||||
type: Type\n\
|
||||
icon: palette\n\
|
||||
color: purple\n\
|
||||
order: 50\n\
|
||||
|
||||
@@ -181,7 +181,7 @@ mod tests {
|
||||
|
||||
seed_vault_themes(vp);
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
assert!(content.contains("type: Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -190,7 +190,7 @@ mod tests {
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
|
||||
let custom = "---\ntype: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
|
||||
fs::write(theme_dir.join("default.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
@@ -227,7 +227,7 @@ mod tests {
|
||||
|
||||
ensure_vault_themes(vp).unwrap();
|
||||
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
|
||||
assert!(content.contains("Is A: Theme"));
|
||||
assert!(content.contains("type: Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -236,7 +236,7 @@ mod tests {
|
||||
let vault = dir.path().join("vault");
|
||||
let theme_dir = vault.join("theme");
|
||||
fs::create_dir_all(&theme_dir).unwrap();
|
||||
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
|
||||
let custom = "---\ntype: Theme\nbackground: \"#123456\"\n---\n";
|
||||
fs::write(theme_dir.join("default.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
@@ -265,7 +265,7 @@ mod tests {
|
||||
"restore must create type/theme.md"
|
||||
);
|
||||
let type_content = fs::read_to_string(vault.join("type").join("theme.md")).unwrap();
|
||||
assert!(type_content.contains("Is A: Type"));
|
||||
assert!(type_content.contains("type: Type"));
|
||||
assert!(type_content.contains("icon: palette"));
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ mod tests {
|
||||
let path = vault.join("type").join("theme.md");
|
||||
assert!(path.exists());
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("Is A: Type"));
|
||||
assert!(content.contains("type: Type"));
|
||||
assert!(content.contains("icon: palette"));
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ mod tests {
|
||||
let vault = dir.path().join("vault");
|
||||
let type_dir = vault.join("type");
|
||||
fs::create_dir_all(&type_dir).unwrap();
|
||||
let custom = "---\nIs A: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
|
||||
let custom = "---\ntype: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
|
||||
fs::write(type_dir.join("theme.md"), custom).unwrap();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{parse_md_file, scan_vault, VaultEntry};
|
||||
|
||||
@@ -25,7 +26,30 @@ fn default_cache_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn cache_path(vault: &Path) -> std::path::PathBuf {
|
||||
/// Compute a deterministic hex hash of the vault path for use as cache filename.
|
||||
fn vault_path_hash(vault: &Path) -> String {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
vault.to_string_lossy().as_ref().hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
/// Return the cache directory. Override with `LAPUTA_CACHE_DIR` env var (for tests).
|
||||
fn cache_dir() -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("LAPUTA_CACHE_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~"))
|
||||
.join(".laputa")
|
||||
.join("cache")
|
||||
}
|
||||
|
||||
fn cache_path(vault: &Path) -> PathBuf {
|
||||
cache_dir().join(format!("{}.json", vault_path_hash(vault)))
|
||||
}
|
||||
|
||||
/// Legacy cache path inside the vault directory (pre-migration).
|
||||
fn legacy_cache_path(vault: &Path) -> PathBuf {
|
||||
vault.join(".laputa-cache.json")
|
||||
}
|
||||
|
||||
@@ -123,11 +147,18 @@ fn load_cache(vault: &Path) -> Option<VaultCache> {
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
/// Write cache atomically: write to a temp file then rename.
|
||||
fn write_cache(vault: &Path, cache: &VaultCache) {
|
||||
if let Ok(data) = serde_json::to_string(cache) {
|
||||
let _ = fs::write(cache_path(vault), data);
|
||||
let final_path = cache_path(vault);
|
||||
if let Some(parent) = final_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp_path = final_path.with_extension("tmp");
|
||||
if let Ok(data) = serde_json::to_string(cache) {
|
||||
if fs::write(&tmp_path, &data).is_ok() {
|
||||
let _ = fs::rename(&tmp_path, &final_path);
|
||||
}
|
||||
}
|
||||
ensure_cache_excluded(vault);
|
||||
}
|
||||
|
||||
/// Normalize an absolute path to a relative path for comparison with git output.
|
||||
@@ -156,52 +187,67 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Machine-local files that should never be git-tracked in any vault.
|
||||
/// These are either caches with absolute paths or per-machine settings.
|
||||
const UNTRACKED_FILES: &[&str] = &[".laputa-cache.json", ".laputa/settings.json"];
|
||||
/// Copy legacy cache data to the new external location atomically.
|
||||
fn copy_legacy_cache_to(legacy: &Path, dest: &Path) {
|
||||
if let Some(parent) = dest.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let tmp_path = dest.with_extension("tmp");
|
||||
if let Ok(data) = fs::read_to_string(legacy) {
|
||||
if fs::write(&tmp_path, &data).is_ok() {
|
||||
let _ = fs::rename(&tmp_path, dest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure machine-local files are excluded from git via `.git/info/exclude`
|
||||
/// and un-tracked if they were previously committed (git rm --cached).
|
||||
/// Called on every cache write so existing vaults self-heal automatically.
|
||||
fn ensure_cache_excluded(vault: &Path) {
|
||||
let git_dir = vault.join(".git");
|
||||
if !git_dir.is_dir() {
|
||||
/// Migrate legacy cache from inside the vault to the new external location.
|
||||
/// Also removes the legacy file from git tracking if present.
|
||||
fn migrate_legacy_cache(vault: &Path) {
|
||||
let legacy = legacy_cache_path(vault);
|
||||
if !legacy.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let exclude_path = git_dir.join("info").join("exclude");
|
||||
|
||||
// 1. Add each entry to .git/info/exclude so git ignores it going forward.
|
||||
let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
|
||||
let mut to_add: Vec<&str> = UNTRACKED_FILES
|
||||
.iter()
|
||||
.filter(|e| !existing.lines().any(|line| line.trim() == **e))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if !to_add.is_empty() {
|
||||
to_add.sort();
|
||||
let separator = if existing.ends_with('\n') || existing.is_empty() {
|
||||
""
|
||||
} else {
|
||||
"\n"
|
||||
};
|
||||
let additions = to_add.join("\n");
|
||||
let _ = fs::write(&exclude_path, format!("{existing}{separator}{additions}\n"));
|
||||
let new_path = cache_path(vault);
|
||||
if !new_path.exists() {
|
||||
copy_legacy_cache_to(&legacy, &new_path);
|
||||
}
|
||||
|
||||
// 2. Un-track each file if git currently tracks it.
|
||||
// `git rm --cached --quiet --ignore-unmatch` exits 0 even if the file isn't tracked.
|
||||
// This fixes existing vaults where these files were committed before this guard.
|
||||
// Remove legacy file from git tracking if present
|
||||
let _ = std::process::Command::new("git")
|
||||
.args(["rm", "--cached", "--quiet", "--ignore-unmatch", "--"])
|
||||
.args(UNTRACKED_FILES)
|
||||
.args([
|
||||
"rm",
|
||||
"--cached",
|
||||
"--quiet",
|
||||
"--ignore-unmatch",
|
||||
".laputa-cache.json",
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
// Delete the legacy file from disk
|
||||
let _ = fs::remove_file(&legacy);
|
||||
}
|
||||
|
||||
/// Remove entries for files that no longer exist on disk and deduplicate
|
||||
/// by case-folded relative path (handles case-insensitive filesystems like macOS APFS).
|
||||
/// Returns `true` if any entries were removed.
|
||||
fn prune_stale_entries(vault: &Path, entries: &mut Vec<VaultEntry>) -> bool {
|
||||
let before = entries.len();
|
||||
// Remove entries whose files no longer exist on disk
|
||||
entries.retain(|e| std::path::Path::new(&e.path).is_file());
|
||||
// Deduplicate by case-folded relative path
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
entries.retain(|e| {
|
||||
let rel = to_relative_path(&e.path, vault).to_lowercase();
|
||||
seen.insert(rel)
|
||||
});
|
||||
entries.len() != before
|
||||
}
|
||||
|
||||
/// Sort entries by modified_at descending and write the cache.
|
||||
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
|
||||
prune_stale_entries(vault, &mut entries);
|
||||
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
write_cache(
|
||||
vault,
|
||||
@@ -216,18 +262,18 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
}
|
||||
|
||||
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
|
||||
/// Always prunes stale entries even when git reports no changes, so that files
|
||||
/// deleted outside git (e.g., via Finder) are removed from the cache on vault open.
|
||||
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
||||
let changed = git_uncommitted_files(vault);
|
||||
if changed.is_empty() {
|
||||
return cache.entries;
|
||||
let mut entries = cache.entries;
|
||||
if !changed.is_empty() {
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault)));
|
||||
entries.extend(parse_files_at(vault, &changed));
|
||||
}
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
let mut entries: Vec<VaultEntry> = cache
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
|
||||
.collect();
|
||||
entries.extend(parse_files_at(vault, &changed));
|
||||
// Always finalize: prune_stale_entries inside finalize_and_cache removes
|
||||
// entries for files deleted outside git (e.g., via Finder or another app).
|
||||
finalize_and_cache(vault, entries, cache.commit_hash)
|
||||
}
|
||||
|
||||
@@ -250,6 +296,14 @@ fn update_different_commit(
|
||||
finalize_and_cache(vault, entries, current_hash)
|
||||
}
|
||||
|
||||
/// Delete the cache file for a vault, forcing a full rescan on the next
|
||||
/// call to `scan_vault_cached`. Used by the `reload_vault` command so that
|
||||
/// explicit user-triggered reloads always read from the filesystem.
|
||||
pub fn invalidate_cache(vault_path: &Path) {
|
||||
let path = cache_path(vault_path);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Scan vault with incremental caching via git.
|
||||
/// Falls back to full scan if cache is missing/corrupt or git is unavailable.
|
||||
pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
@@ -260,6 +314,9 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
));
|
||||
}
|
||||
|
||||
// Migrate legacy in-vault cache to external location on first run
|
||||
migrate_legacy_cache(vault_path);
|
||||
|
||||
let current_hash = match git_head_hash(vault_path) {
|
||||
Some(h) => h,
|
||||
None => return scan_vault(vault_path),
|
||||
@@ -289,8 +346,19 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Serialize all cache tests that mutate the LAPUTA_CACHE_DIR env var.
|
||||
/// `std::env::set_var` is process-global, so parallel tests would race.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Set up a temporary cache directory for test isolation.
|
||||
/// Caller MUST hold `ENV_LOCK` for the duration of the test.
|
||||
fn set_test_cache_dir(dir: &Path) {
|
||||
std::env::set_var("LAPUTA_CACHE_DIR", dir.to_string_lossy().as_ref());
|
||||
}
|
||||
|
||||
fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
let file_path = dir.join(name);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
@@ -300,8 +368,157 @@ mod tests {
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
fn init_git_repo(vault: &Path) {
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Common setup: acquire env lock, create temp cache dir + git-initialised vault.
|
||||
/// Returns (lock_guard, cache_tmpdir, vault_tmpdir) — keep all alive for the test.
|
||||
fn setup_git_vault() -> (std::sync::MutexGuard<'static, ()>, TempDir, TempDir) {
|
||||
let lock = ENV_LOCK.lock().unwrap();
|
||||
let cache_tmp = TempDir::new().unwrap();
|
||||
set_test_cache_dir(cache_tmp.path());
|
||||
let vault_tmp = TempDir::new().unwrap();
|
||||
init_git_repo(vault_tmp.path());
|
||||
(lock, cache_tmp, vault_tmp)
|
||||
}
|
||||
|
||||
fn git_add_commit(vault: &Path, msg: &str) {
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", msg])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_path_is_outside_vault() {
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
let cache_dir = TempDir::new().unwrap();
|
||||
set_test_cache_dir(cache_dir.path());
|
||||
|
||||
let vault = Path::new("/Users/test/MyVault");
|
||||
let path = cache_path(vault);
|
||||
|
||||
// Cache must NOT be inside the vault
|
||||
assert!(
|
||||
!path.starts_with(vault),
|
||||
"cache path must be outside the vault, got: {}",
|
||||
path.display()
|
||||
);
|
||||
// Cache must be under the cache directory
|
||||
assert!(
|
||||
path.starts_with(cache_dir.path()),
|
||||
"cache path must be under cache dir, got: {}",
|
||||
path.display()
|
||||
);
|
||||
// Must end with .json
|
||||
assert_eq!(path.extension().unwrap(), "json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_path_hash_is_deterministic() {
|
||||
let hash1 = vault_path_hash(Path::new("/Users/test/MyVault"));
|
||||
let hash2 = vault_path_hash(Path::new("/Users/test/MyVault"));
|
||||
assert_eq!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_vaults_get_different_hashes() {
|
||||
let hash1 = vault_path_hash(Path::new("/Users/test/Vault1"));
|
||||
let hash2 = vault_path_hash(Path::new("/Users/test/Vault2"));
|
||||
assert_ne!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atomic_write_no_tmp_file_left() {
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
let cache_dir = TempDir::new().unwrap();
|
||||
set_test_cache_dir(cache_dir.path());
|
||||
|
||||
let vault_dir = TempDir::new().unwrap();
|
||||
let vault = vault_dir.path();
|
||||
|
||||
let cache = VaultCache {
|
||||
version: CACHE_VERSION,
|
||||
vault_path: vault.to_string_lossy().to_string(),
|
||||
commit_hash: "abc123".to_string(),
|
||||
entries: vec![],
|
||||
};
|
||||
|
||||
write_cache(vault, &cache);
|
||||
|
||||
// Final file should exist
|
||||
let final_path = cache_path(vault);
|
||||
assert!(final_path.exists(), "cache file must exist after write");
|
||||
|
||||
// Tmp file should NOT exist (renamed away)
|
||||
let tmp_path = final_path.with_extension("tmp");
|
||||
assert!(
|
||||
!tmp_path.exists(),
|
||||
"tmp file must not exist after atomic write"
|
||||
);
|
||||
|
||||
// Content must be valid JSON
|
||||
let data = fs::read_to_string(&final_path).unwrap();
|
||||
let loaded: VaultCache = serde_json::from_str(&data).unwrap();
|
||||
assert_eq!(loaded.commit_hash, "abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_cache_migration() {
|
||||
let (_lock, _cache_tmp, vault_dir) = setup_git_vault();
|
||||
let vault = vault_dir.path();
|
||||
|
||||
// Create a legacy cache file inside the vault
|
||||
let legacy = legacy_cache_path(vault);
|
||||
let cache = VaultCache {
|
||||
version: CACHE_VERSION,
|
||||
vault_path: vault.to_string_lossy().to_string(),
|
||||
commit_hash: "old123".to_string(),
|
||||
entries: vec![],
|
||||
};
|
||||
fs::write(&legacy, serde_json::to_string(&cache).unwrap()).unwrap();
|
||||
|
||||
// Run migration
|
||||
migrate_legacy_cache(vault);
|
||||
|
||||
// New cache file should exist with migrated data
|
||||
let new_path = cache_path(vault);
|
||||
assert!(new_path.exists(), "migrated cache must exist");
|
||||
let data = fs::read_to_string(&new_path).unwrap();
|
||||
let loaded: VaultCache = serde_json::from_str(&data).unwrap();
|
||||
assert_eq!(loaded.commit_hash, "old123");
|
||||
|
||||
// Legacy file should be deleted
|
||||
assert!(!legacy.exists(), "legacy cache file must be removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_no_git() {
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
let cache_dir = TempDir::new().unwrap();
|
||||
set_test_cache_dir(cache_dir.path());
|
||||
|
||||
// Without git, scan_vault_cached falls back to scan_vault
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "note.md", "# Note\n\nContent here.");
|
||||
@@ -314,43 +531,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_with_git() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init git repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "note.md", "# Note\n\nFirst version.");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// First call: full scan, writes cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(cache_path(vault).exists());
|
||||
|
||||
// Cache must NOT be inside the vault
|
||||
assert!(
|
||||
!cache_path(vault).starts_with(vault),
|
||||
"cache must be outside the vault"
|
||||
);
|
||||
|
||||
// Second call: uses cache (same HEAD)
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
@@ -359,37 +556,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_invalidates_stale_vault_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init git repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "note.md", "# Note\n\nContent.");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Build cache normally
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -424,37 +595,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_cached_incremental_different_commit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init git repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "first.md", "# First\n\nFirst note.");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "first"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "first");
|
||||
|
||||
// Build cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -462,16 +607,7 @@ mod tests {
|
||||
|
||||
// Add a second file and commit
|
||||
create_test_file(vault, "second.md", "# Second\n\nSecond note.");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "second"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "second");
|
||||
|
||||
// Incremental update: cache has old commit, new commit adds second.md
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
@@ -483,37 +619,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_picks_up_modified_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Commit a type note without sidebar label
|
||||
create_test_file(vault, "type/news.md", "---\ntype: Type\n---\n# News\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime the cache (same commit hash)
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -539,36 +650,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_new_file_still_added() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "existing.md", "# Existing\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -587,36 +673,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_new_files_in_new_subdirectory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
create_test_file(vault, "existing.md", "# Existing\n");
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -649,41 +710,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_visible_removed_from_type_note() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Commit a type note with visible: false
|
||||
create_test_file(
|
||||
vault,
|
||||
"type/topic.md",
|
||||
"---\ntype: Type\nvisible: false\n---\n# Topic\n",
|
||||
);
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime the cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
@@ -705,4 +741,133 @@ mod tests {
|
||||
"visible must be None after removing the field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deleted_file_removed_from_cache_on_rescan() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "keep.md", "# Keep\n\nStays.");
|
||||
create_test_file(vault, "remove.md", "# Remove\n\nGoes away.");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime cache with both files
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
// Delete file via filesystem (simulates Finder delete)
|
||||
fs::remove_file(vault.join("remove.md")).unwrap();
|
||||
// Also stage the deletion so git status is clean for this file
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "remove.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Rescan — deleted file must be pruned
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1, "deleted file must be pruned on rescan");
|
||||
assert_eq!(entries2[0].title, "Keep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deleted_untracked_file_removed_from_cache() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "tracked.md", "# Tracked\n\nCommitted.");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Create untracked file and prime cache
|
||||
create_test_file(vault, "temp.md", "# Temp\n\nUntracked.");
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
// Delete the untracked file via filesystem
|
||||
fs::remove_file(vault.join("temp.md")).unwrap();
|
||||
|
||||
// Rescan — untracked deleted file must be pruned
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(
|
||||
entries2.len(),
|
||||
1,
|
||||
"deleted untracked file must be pruned on rescan"
|
||||
);
|
||||
assert_eq!(entries2[0].title, "Tracked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_rename_no_duplicates() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "Note.md", "# Note\n\nOriginal case.");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Prime cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
|
||||
// Simulate case-only rename on case-insensitive FS: delete old, create new
|
||||
fs::remove_file(vault.join("Note.md")).unwrap();
|
||||
create_test_file(vault, "note.md", "# Note\n\nRenamed case.");
|
||||
git_add_commit(vault, "rename");
|
||||
|
||||
// Rescan — must not have duplicates
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(
|
||||
entries2.len(),
|
||||
1,
|
||||
"case-only rename must not create duplicates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalidate_cache_deletes_cache_file() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "note.md", "# Note\n\nContent.");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Build cache
|
||||
let _ = scan_vault_cached(vault).unwrap();
|
||||
assert!(cache_path(vault).exists(), "cache file must exist");
|
||||
|
||||
// Invalidate
|
||||
invalidate_cache(vault);
|
||||
assert!(
|
||||
!cache_path(vault).exists(),
|
||||
"cache file must be deleted after invalidation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalidate_then_scan_forces_full_rescan() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "note.md", "---\nTrashed: false\n---\n# Note\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Build cache — note is not trashed
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(!entries[0].trashed, "note must not be trashed initially");
|
||||
|
||||
// Simulate trashing the note on disk (update frontmatter directly)
|
||||
create_test_file(vault, "note.md", "---\nTrashed: true\n---\n# Note\n");
|
||||
// Stage the change so git sees it
|
||||
git_add_commit(vault, "trash");
|
||||
|
||||
// Without invalidation, scan_vault_cached uses incremental update.
|
||||
// With invalidation, it must do a full rescan from disk.
|
||||
invalidate_cache(vault);
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert!(
|
||||
entries2[0].trashed,
|
||||
"note must be trashed after invalidate + rescan"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ YAML frontmatter between `---` delimiters defines metadata:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Project
|
||||
type: Project
|
||||
Status: Active
|
||||
Owner: "[[person/jane-doe]]"
|
||||
Belongs to: "[[quarter/24q1]]"
|
||||
@@ -65,7 +65,7 @@ Related to:
|
||||
|
||||
| Field | Purpose |
|
||||
|-------|---------|
|
||||
| `Is A` | Entity type (usually inferred from folder) |
|
||||
| `type` | Entity type (usually inferred from folder) |
|
||||
| `Status` | Active, Done, Paused, Archived, Dropped |
|
||||
| `Owner` | Person responsible (wikilink) |
|
||||
| `Belongs to` | Parent relationship(s) |
|
||||
@@ -98,7 +98,7 @@ Files in `type/` define entity types and control how they appear in the sidebar:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Type
|
||||
type: Type
|
||||
icon: rocket-launch
|
||||
color: purple
|
||||
order: 1
|
||||
@@ -119,32 +119,32 @@ Available colors: red, purple, blue, green, yellow, orange. Icons are Phosphor n
|
||||
const SAMPLE_FILES: &[SampleFile] = &[
|
||||
SampleFile {
|
||||
rel_path: "type/project.md",
|
||||
content: "---\nIs A: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n",
|
||||
content: "---\ntype: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/note.md",
|
||||
content: "---\nIs A: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n",
|
||||
content: "---\ntype: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/person.md",
|
||||
content: "---\nIs A: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n",
|
||||
content: "---\ntype: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/topic.md",
|
||||
content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
|
||||
content: "---\ntype: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/theme.md",
|
||||
content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
|
||||
content: "---\ntype: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/config.md",
|
||||
content: "---\nIs A: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
|
||||
content: "---\ntype: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "note/welcome-to-laputa.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
type: Note
|
||||
Related to:
|
||||
- "[[note/editor-basics]]"
|
||||
- "[[note/using-properties]]"
|
||||
@@ -179,7 +179,7 @@ Every note is a markdown file with optional YAML frontmatter at the top. Notes l
|
||||
SampleFile {
|
||||
rel_path: "note/editor-basics.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
type: Note
|
||||
Related to: "[[note/welcome-to-laputa]]"
|
||||
---
|
||||
|
||||
@@ -227,7 +227,7 @@ function hello() {
|
||||
SampleFile {
|
||||
rel_path: "note/using-properties.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
type: Note
|
||||
Status: Active
|
||||
Related to:
|
||||
- "[[note/welcome-to-laputa]]"
|
||||
@@ -240,7 +240,7 @@ Every note can have **properties** defined in the YAML frontmatter at the top of
|
||||
|
||||
## Common properties
|
||||
|
||||
- **Is A** — The note's type (Project, Note, Person, etc.)
|
||||
- **type** — The note's type (Project, Note, Person, etc.)
|
||||
- **Status** — Current state: Active, Done, Paused, Archived, Dropped
|
||||
- **Belongs to** — Parent relationship (e.g., a project belongs to a quarter)
|
||||
- **Related to** — Lateral connections to other notes
|
||||
@@ -261,7 +261,7 @@ You can add any custom property. If the value contains `[[wiki-links]]`, Laputa
|
||||
SampleFile {
|
||||
rel_path: "note/wiki-links-and-relationships.md",
|
||||
content: r#"---
|
||||
Is A: Note
|
||||
type: Note
|
||||
Related to:
|
||||
- "[[note/welcome-to-laputa]]"
|
||||
- "[[note/using-properties]]"
|
||||
@@ -300,7 +300,7 @@ Over time, your wiki-links form a rich web of connections. Use the **Referenced
|
||||
SampleFile {
|
||||
rel_path: "project/sample-project.md",
|
||||
content: r#"---
|
||||
Is A: Project
|
||||
type: Project
|
||||
Status: Active
|
||||
Owner: "[[person/sample-collaborator]]"
|
||||
Related to: "[[topic/getting-started]]"
|
||||
@@ -329,7 +329,7 @@ This project is owned by [[person/sample-collaborator]] and relates to [[topic/g
|
||||
SampleFile {
|
||||
rel_path: "person/sample-collaborator.md",
|
||||
content: r#"---
|
||||
Is A: Person
|
||||
type: Person
|
||||
---
|
||||
|
||||
# Sample Collaborator
|
||||
@@ -350,7 +350,7 @@ This person is the owner of [[project/sample-project]]. Check the **Referenced B
|
||||
SampleFile {
|
||||
rel_path: "topic/getting-started.md",
|
||||
content: r#"---
|
||||
Is A: Topic
|
||||
type: Topic
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
@@ -7,13 +7,13 @@ mod parsing;
|
||||
mod rename;
|
||||
mod trash;
|
||||
|
||||
pub use cache::scan_vault_cached;
|
||||
pub use cache::{invalidate_cache, scan_vault_cached};
|
||||
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{rename_note, RenameResult};
|
||||
pub use trash::{delete_note, purge_trash};
|
||||
pub use rename::{move_note_to_type_folder, rename_note, MoveResult, RenameResult};
|
||||
pub use trash::{delete_note, is_file_trashed, purge_trash};
|
||||
|
||||
use parsing::{
|
||||
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
|
||||
@@ -94,7 +94,7 @@ pub struct VaultEntry {
|
||||
/// Intermediate struct to capture YAML frontmatter fields.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct Frontmatter {
|
||||
#[serde(rename = "Is A", alias = "type")]
|
||||
#[serde(rename = "type", alias = "Is A", alias = "is_a")]
|
||||
is_a: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
aliases: Option<StringOrList>,
|
||||
@@ -108,9 +108,19 @@ struct Frontmatter {
|
||||
owner: Option<String>,
|
||||
#[serde(rename = "Cadence")]
|
||||
cadence: Option<String>,
|
||||
#[serde(rename = "Archived")]
|
||||
#[serde(
|
||||
rename = "Archived",
|
||||
alias = "archived",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
archived: Option<bool>,
|
||||
#[serde(rename = "Trashed", alias = "trashed")]
|
||||
#[serde(
|
||||
rename = "Trashed",
|
||||
alias = "trashed",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
trashed: Option<bool>,
|
||||
#[serde(rename = "Trashed at", alias = "trashed_at")]
|
||||
trashed_at: Option<String>,
|
||||
@@ -136,6 +146,56 @@ struct Frontmatter {
|
||||
visible: Option<bool>,
|
||||
}
|
||||
|
||||
/// Custom deserializer for boolean fields that may arrive as strings.
|
||||
/// YAML `Yes`/`No` get converted to JSON strings by gray_matter, so we
|
||||
/// need to accept both actual booleans and their string representations.
|
||||
fn deserialize_bool_or_string<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct BoolOrStringVisitor;
|
||||
|
||||
impl<'de> de::Visitor<'de> for BoolOrStringVisitor {
|
||||
type Value = Option<bool>;
|
||||
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a boolean or a string representing a boolean")
|
||||
}
|
||||
|
||||
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
|
||||
match v.to_lowercase().as_str() {
|
||||
"true" | "yes" | "1" => Ok(Some(true)),
|
||||
"false" | "no" | "0" | "" => Ok(Some(false)),
|
||||
_ => Ok(Some(false)),
|
||||
}
|
||||
}
|
||||
|
||||
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
|
||||
Ok(Some(v != 0))
|
||||
}
|
||||
|
||||
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
|
||||
Ok(Some(v != 0))
|
||||
}
|
||||
|
||||
fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(BoolOrStringVisitor)
|
||||
}
|
||||
|
||||
/// Handles YAML fields that can be either a single string or a list of strings.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
@@ -435,6 +495,15 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read a single file from disk and return a fresh VaultEntry.
|
||||
/// Used after failed optimistic updates to restore the true filesystem state.
|
||||
pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("File does not exist: {}", path.display()));
|
||||
}
|
||||
parse_md_file(path)
|
||||
}
|
||||
|
||||
/// Read the content of a single note file.
|
||||
pub fn get_note_content(path: &Path) -> Result<String, String> {
|
||||
if !path.exists() {
|
||||
@@ -543,6 +612,35 @@ mod tests {
|
||||
parse_md_file(&dir.path().join(name)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_entry_returns_fresh_data() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"note.md",
|
||||
"---\nStatus: Active\n---\n# My Note\n\nOriginal.",
|
||||
);
|
||||
let entry = reload_entry(&dir.path().join("note.md")).unwrap();
|
||||
assert_eq!(entry.title, "My Note");
|
||||
assert_eq!(entry.status, Some("Active".to_string()));
|
||||
|
||||
// Modify on disk and reload — must see the new content
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"note.md",
|
||||
"---\nStatus: Done\n---\n# My Note\n\nUpdated.",
|
||||
);
|
||||
let fresh = reload_entry(&dir.path().join("note.md")).unwrap();
|
||||
assert_eq!(fresh.status, Some("Done".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reload_entry_nonexistent_file() {
|
||||
let result = reload_entry(std::path::Path::new("/nonexistent/path/note.md"));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
const FULL_FM_CONTENT: &str = "---\nIs A: Project\naliases:\n - Laputa\n - Castle in the Sky\nBelongs to:\n - Studio Ghibli\nRelated to:\n - Miyazaki\nStatus: Active\nOwner: Luca\nCadence: Weekly\n---\n# Laputa Project\n\nThis is a project note.\n";
|
||||
|
||||
#[test]
|
||||
@@ -1374,6 +1472,25 @@ Company: Acme Corp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_lowercase_alias() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\narchived: true\n---\n# Old Quarter\n";
|
||||
let entry = parse_test_entry(&dir, "old-quarter.md", content);
|
||||
assert!(
|
||||
entry.archived,
|
||||
"lowercase 'archived' must be parsed via alias (frontend writes lowercase)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_titlecase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: true\n---\n# Old Quarter\n";
|
||||
let entry = parse_test_entry(&dir, "old-quarter-2.md", content);
|
||||
assert!(entry.archived, "titlecase 'Archived' must also be parsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trashed_false_when_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1383,6 +1500,91 @@ Company: Acme Corp
|
||||
assert!(entry.trashed_at.is_none());
|
||||
}
|
||||
|
||||
// --- archived/trashed string-value tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_yes_titlecase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: Yes\n---\n# Old\n";
|
||||
let entry = parse_test_entry(&dir, "old.md", content);
|
||||
assert!(entry.archived, "'Archived: Yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_yes_lowercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\narchived: yes\n---\n# Old\n";
|
||||
let entry = parse_test_entry(&dir, "old2.md", content);
|
||||
assert!(entry.archived, "'archived: yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_yes_uppercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: YES\n---\n# Old\n";
|
||||
let entry = parse_test_entry(&dir, "old3.md", content);
|
||||
assert!(entry.archived, "'Archived: YES' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_no() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: No\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active2.md", content);
|
||||
assert!(!entry.archived, "'Archived: No' must be parsed as false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_false_string() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: \"false\"\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active3.md", content);
|
||||
assert!(
|
||||
!entry.archived,
|
||||
"'Archived: \"false\"' must be parsed as false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_zero() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nArchived: 0\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active4.md", content);
|
||||
assert!(!entry.archived, "'Archived: 0' must be parsed as false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Note\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active5.md", content);
|
||||
assert!(!entry.archived, "absent archived must default to false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_yes_titlecase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: Yes\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone2.md", content);
|
||||
assert!(entry.trashed, "'Trashed: Yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_yes_lowercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntrashed: yes\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone3.md", content);
|
||||
assert!(entry.trashed, "'trashed: yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_no() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: No\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active6.md", content);
|
||||
assert!(!entry.trashed, "'Trashed: No' must be parsed as false");
|
||||
}
|
||||
|
||||
// --- visible field tests ---
|
||||
|
||||
#[test]
|
||||
@@ -1425,6 +1627,32 @@ Company: Acme Corp
|
||||
assert!(entry.properties.get("visible").is_none());
|
||||
}
|
||||
|
||||
// --- round-trip: canonical `type:` field and `Is A:` alias ---
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_type_key_parses_correctly() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Quarter\n---\n# Q1 2026\n";
|
||||
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
|
||||
assert_eq!(entry.is_a, Some("Quarter".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_is_a_alias_still_works() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Quarter\n---\n# Q1 2026\n";
|
||||
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
|
||||
assert_eq!(entry.is_a, Some("Quarter".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_is_a_snake_case_alias_still_works() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nis_a: Quarter\n---\n# Q1 2026\n";
|
||||
let entry = parse_test_entry(&dir, "quarter/q1.md", content);
|
||||
assert_eq!(entry.is_a, Some("Quarter".to_string()));
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -18,12 +18,18 @@ pub(super) fn extract_title(content: &str, filename: &str) -> String {
|
||||
}
|
||||
|
||||
/// Remove YAML frontmatter (triple-dash delimited) from content.
|
||||
/// The closing `---` must appear at the start of a line to avoid matching
|
||||
/// occurrences inside frontmatter values (e.g. `title: foo---bar`).
|
||||
fn strip_frontmatter(content: &str) -> &str {
|
||||
let Some(rest) = content.strip_prefix("---") else {
|
||||
return content;
|
||||
};
|
||||
match rest.find("---") {
|
||||
Some(end) => rest[end + 3..].trim_start(),
|
||||
// Find closing `---` at the start of a line (preceded by newline)
|
||||
match rest.find("\n---") {
|
||||
Some(end) => {
|
||||
let after = end + 4; // skip past "\n---"
|
||||
rest[after..].trim_start()
|
||||
}
|
||||
None => content,
|
||||
}
|
||||
}
|
||||
@@ -384,6 +390,46 @@ mod tests {
|
||||
assert_eq!(count_body_words(content), 6);
|
||||
}
|
||||
|
||||
// --- strip_frontmatter tests ---
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_basic() {
|
||||
let content = "---\ntitle: Test\n---\nBody content.";
|
||||
assert_eq!(strip_frontmatter(content), "Body content.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_no_frontmatter() {
|
||||
let content = "Just plain content.";
|
||||
assert_eq!(strip_frontmatter(content), "Just plain content.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_dashes_in_value() {
|
||||
// The closing --- must be at line start, not inside a value
|
||||
let content = "---\ntitle: foo---bar\nstatus: active\n---\nBody here.";
|
||||
assert_eq!(strip_frontmatter(content), "Body here.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_unclosed() {
|
||||
let content = "---\ntitle: Test\nNo closing fence";
|
||||
assert_eq!(strip_frontmatter(content), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_frontmatter_empty_body() {
|
||||
let content = "---\ntitle: Test\n---\n";
|
||||
assert_eq!(strip_frontmatter(content), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_count_body_words_with_dashes_in_frontmatter_value() {
|
||||
// Regression: strip_frontmatter previously matched --- inside values
|
||||
let content = "---\ntitle: my---note\nstatus: active\n---\n# Title\n\nThree body words.";
|
||||
assert_eq!(count_body_words(content), 3);
|
||||
}
|
||||
|
||||
// --- strip_markdown_chars tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -166,6 +166,183 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
|
||||
.unwrap_or(abs_path)
|
||||
}
|
||||
|
||||
/// Result of a move-to-type-folder operation.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct MoveResult {
|
||||
/// New absolute file path after move (same as old if no move happened).
|
||||
pub new_path: String,
|
||||
/// Number of other files updated (wikilink replacements).
|
||||
pub updated_links: usize,
|
||||
/// Whether the file was actually moved (false if already in the right folder).
|
||||
pub moved: bool,
|
||||
}
|
||||
|
||||
/// Convert a type name to a folder slug. All known types are single lowercase words;
|
||||
/// unknown types are slugified (lowercase, non-alphanumeric → hyphen).
|
||||
fn type_to_folder_slug(type_name: &str) -> String {
|
||||
type_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<&str>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
|
||||
fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
|
||||
let dest = dest_dir.join(filename);
|
||||
if !dest.exists() {
|
||||
return dest;
|
||||
}
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let ext = Path::new(filename)
|
||||
.extension()
|
||||
.map(|s| format!(".{}", s.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let mut counter = 2;
|
||||
loop {
|
||||
let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a note to the folder corresponding to its new type, and update wikilinks across the vault.
|
||||
///
|
||||
/// Returns `MoveResult` with `moved: false` if the note is already in the correct folder.
|
||||
/// Creates the target folder if it does not exist.
|
||||
pub fn move_note_to_type_folder(
|
||||
vault_path: &str,
|
||||
note_path: &str,
|
||||
new_type: &str,
|
||||
) -> Result<MoveResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(note_path);
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", note_path));
|
||||
}
|
||||
let new_type = new_type.trim();
|
||||
if new_type.is_empty() {
|
||||
return Err("Type cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let folder_slug = type_to_folder_slug(new_type);
|
||||
|
||||
// Check if already in the correct folder
|
||||
let current_folder = old_file
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if current_folder == folder_slug {
|
||||
return Ok(MoveResult {
|
||||
new_path: note_path.to_string(),
|
||||
updated_links: 0,
|
||||
moved: false,
|
||||
});
|
||||
}
|
||||
|
||||
let filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Create target directory if needed
|
||||
let dest_dir = vault.join(&folder_slug);
|
||||
if !dest_dir.exists() {
|
||||
fs::create_dir_all(&dest_dir)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", dest_dir.display(), e))?;
|
||||
}
|
||||
|
||||
// Determine destination path (handle collisions)
|
||||
let new_file = unique_dest_path(&dest_dir, &filename);
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
// Read content and move
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
|
||||
fs::write(&new_file, &content)
|
||||
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
|
||||
fs::remove_file(old_file)
|
||||
.map_err(|e| format!("Failed to remove old file {}: {}", note_path, e))?;
|
||||
|
||||
// Extract title for wikilink matching
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let old_title = super::extract_title(&content, &old_filename);
|
||||
|
||||
// Update wikilinks across the vault (title stays the same, path changes)
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(note_path, &vault_prefix);
|
||||
let new_path_stem = to_path_stem(&new_path_str, &vault_prefix);
|
||||
|
||||
// Build pattern matching old path stem (e.g. "note/weekly-review")
|
||||
let re = match build_wikilink_pattern(&old_title, old_path_stem) {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Ok(MoveResult {
|
||||
new_path: new_path_str,
|
||||
updated_links: 0,
|
||||
moved: true,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Determine the replacement: if path-style wikilinks were used, update to new path.
|
||||
// Title-style wikilinks [[My Note]] stay the same (title hasn't changed).
|
||||
let files = collect_md_files(vault, &new_file);
|
||||
let updated_links = files
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let file_content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if !re.is_match(&file_content) {
|
||||
return false;
|
||||
}
|
||||
// Replace path-based wikilinks (old_path_stem → new_path_stem)
|
||||
// and keep title-based wikilinks as-is.
|
||||
let replaced = re.replace_all(&file_content, |caps: ®ex::Captures| {
|
||||
let full_match = caps.get(0).map(|m| m.as_str()).unwrap_or("");
|
||||
let pipe = caps.get(1);
|
||||
// If the match used the path stem, replace with new path stem
|
||||
if full_match.contains(old_path_stem) {
|
||||
match pipe {
|
||||
Some(p) => format!("[[{}{}]]", new_path_stem, p.as_str()),
|
||||
None => format!("[[{}]]", new_path_stem),
|
||||
}
|
||||
} else {
|
||||
// Title-based link — keep as-is (title hasn't changed)
|
||||
full_match.to_string()
|
||||
}
|
||||
});
|
||||
if replaced != file_content {
|
||||
fs::write(path, replaced.as_ref()).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.count();
|
||||
|
||||
Ok(MoveResult {
|
||||
new_path: new_path_str,
|
||||
updated_links,
|
||||
moved: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
|
||||
pub fn rename_note(
|
||||
vault_path: &str,
|
||||
@@ -471,4 +648,223 @@ mod tests {
|
||||
assert!(content.contains("title: Renamed Note"));
|
||||
assert!(content.contains("# Renamed Note"));
|
||||
}
|
||||
|
||||
// --- move_note_to_type_folder tests ---
|
||||
|
||||
#[test]
|
||||
fn test_type_to_folder_slug_known_types() {
|
||||
assert_eq!(type_to_folder_slug("Person"), "person");
|
||||
assert_eq!(type_to_folder_slug("Project"), "project");
|
||||
assert_eq!(type_to_folder_slug("Quarter"), "quarter");
|
||||
assert_eq!(type_to_folder_slug("Note"), "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_to_folder_slug_unknown_types() {
|
||||
assert_eq!(type_to_folder_slug("Key Result"), "key-result");
|
||||
assert_eq!(type_to_folder_slug("My Custom Type"), "my-custom-type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_basic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n\nContent here.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert!(result.new_path.contains("/quarter/weekly-review.md"));
|
||||
assert!(!old_path.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_already_in_correct_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"quarter/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n",
|
||||
);
|
||||
|
||||
let path = vault.join("quarter/weekly-review.md");
|
||||
let result =
|
||||
move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "Quarter")
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.moved);
|
||||
assert_eq!(result.new_path, path.to_str().unwrap());
|
||||
assert_eq!(result.updated_links, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_creates_target_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/my-note.md",
|
||||
"---\ntype: Quarter\n---\n# My Note\n",
|
||||
);
|
||||
|
||||
let dest_dir = vault.join("quarter");
|
||||
assert!(!dest_dir.exists());
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert!(dest_dir.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_filename_collision() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/my-note.md",
|
||||
"---\ntype: Quarter\n---\n# My Note\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"quarter/my-note.md",
|
||||
"---\ntype: Quarter\n---\n# Existing Note\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert!(result.new_path.contains("/quarter/my-note-2.md"));
|
||||
assert!(!old_path.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
// Original file should still exist
|
||||
assert!(vault.join("quarter/my-note.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_updates_path_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n\nContent.\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"project/my-project.md",
|
||||
"---\ntype: Project\n---\n# My Project\n\nSee [[note/weekly-review]] for details.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
assert_eq!(result.updated_links, 1);
|
||||
|
||||
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
|
||||
assert!(project_content.contains("[[quarter/weekly-review]]"));
|
||||
assert!(!project_content.contains("[[note/weekly-review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_preserves_title_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/weekly-review.md",
|
||||
"---\ntype: Quarter\n---\n# Weekly Review\n",
|
||||
);
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/other.md",
|
||||
"---\ntype: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
// Title-based wikilinks should be unchanged
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Weekly Review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_empty_type_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/test.md", "# Test\n");
|
||||
|
||||
let path = vault.join("note/test.md");
|
||||
let result = move_note_to_type_folder(vault.to_str().unwrap(), path.to_str().unwrap(), "");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_nonexistent_file_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("note/nope.md").to_str().unwrap(),
|
||||
"Quarter",
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_preserves_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let original = "---\ntype: Quarter\ntitle: My Note\n---\n# My Note\n\nImportant content.\n";
|
||||
create_test_file(vault, "note/my-note.md", original);
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert_eq!(content, original);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,37 @@ pub fn delete_note(path: &str) -> Result<String, String> {
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
/// Check whether a file's frontmatter marks it as trashed.
|
||||
/// Returns `true` if `Trashed: true` or `Trashed at` is present.
|
||||
pub fn is_file_trashed(path: &Path) -> bool {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&content);
|
||||
|
||||
// Check for "Trashed at" field — its presence implies trashed
|
||||
if extract_trashed_at_string(&parsed.data).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for "Trashed: true"
|
||||
if let Some(gray_matter::Pod::Hash(ref map)) = parsed.data {
|
||||
if let Some(pod) = map.get("Trashed").or_else(|| map.get("trashed")) {
|
||||
return match pod {
|
||||
gray_matter::Pod::Boolean(b) => *b,
|
||||
gray_matter::Pod::String(s) => {
|
||||
matches!(s.to_ascii_lowercase().as_str(), "yes" | "true")
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete those where
|
||||
/// `Trashed at` frontmatter is more than 30 days ago.
|
||||
/// Returns the list of deleted file paths.
|
||||
@@ -155,7 +186,7 @@ mod tests {
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"normal.md",
|
||||
"---\nIs A: Note\n---\n# Normal Note\n",
|
||||
"---\ntype: Note\n---\n# Normal Note\n",
|
||||
);
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
@@ -248,4 +279,67 @@ mod tests {
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("old.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\nTrashed: true\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\nTrashed at: \"2026-01-01\"\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_yes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "trashed.md", "---\nTrashed: Yes\n---\n# Gone\n");
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_normal_note() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
assert!(!is_file_trashed(&dir.path().join("normal.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_archived_not_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"archived.md",
|
||||
"---\nArchived: true\n---\n# Archived\n",
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("archived.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_nonexistent_file() {
|
||||
assert!(!is_file_trashed(Path::new("/nonexistent/path.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_false() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"active.md",
|
||||
"---\nTrashed: false\n---\n# Active\n",
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("active.md")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"endpoints": [
|
||||
"https://refactoringhq.github.io/laputa-app/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFQzlGQ0RFM0E1MTIzNDkKUldSSkkxRTYzdnpKVG13M0Zwd3M1RzErbWhJeEhBQUQyaG90bHBtMkNzMm1MNERZRlpXSGFRMTUK"
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE4NkQ5MDI3REVCRkFGNUMKUldSY3I3L2VKNUJ0cU5JRlRZZlp3NGhnU3ZwbkVKeGVvREpmb2sxRVJndHFpVFZPNlArbEE5R1IK"
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/App.tsx
54
src/App.tsx
@@ -46,6 +46,7 @@ import type { SidebarSelection, VaultEntry } from './types'
|
||||
import type { NoteListItem } from './utils/ai-context'
|
||||
import { filterEntries } from './utils/noteListHelpers'
|
||||
import { openLocalFile } from './utils/url'
|
||||
import { flushEditorContent } from './utils/autoSave'
|
||||
import './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -64,13 +65,21 @@ function useBulkActions(
|
||||
setToastMessage: (msg: string | null) => void,
|
||||
) {
|
||||
const handleBulkArchive = useCallback(async (paths: string[]) => {
|
||||
for (const path of paths) await entryActions.handleArchiveNote(path)
|
||||
setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} archived`)
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try { await entryActions.handleArchiveNote(path); ok++ }
|
||||
catch { /* error toast already shown by flushBeforeAction */ }
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} archived`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
const handleBulkTrash = useCallback(async (paths: string[]) => {
|
||||
for (const path of paths) await entryActions.handleTrashNote(path)
|
||||
setToastMessage(`${paths.length} note${paths.length > 1 ? 's' : ''} moved to trash`)
|
||||
let ok = 0
|
||||
for (const path of paths) {
|
||||
try { await entryActions.handleTrashNote(path); ok++ }
|
||||
catch { /* error toast already shown by flushBeforeAction */ }
|
||||
}
|
||||
if (ok > 0) setToastMessage(`${ok} note${ok > 1 ? 's' : ''} moved to trash`)
|
||||
}, [entryActions, setToastMessage])
|
||||
|
||||
return { handleBulkArchive, handleBulkTrash }
|
||||
@@ -109,7 +118,7 @@ function App() {
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
useVaultConfig(resolvedPath)
|
||||
const { settings, saveSettings } = useSettings()
|
||||
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent)
|
||||
const themeManager = useThemeManager(resolvedPath, vault.entries)
|
||||
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
@@ -173,7 +182,7 @@ function App() {
|
||||
// Read at callback time, so it's always current when user presses Cmd+N.
|
||||
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
|
||||
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
|
||||
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
|
||||
|
||||
// Keep tab entries in sync with vault entries so banners (trash/archive)
|
||||
// and read-only state react immediately without reopening the note.
|
||||
@@ -298,12 +307,34 @@ function App() {
|
||||
}, [vault, triggerIncrementalIndex])
|
||||
|
||||
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
|
||||
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
|
||||
updateEntry: vault.updateEntry,
|
||||
setTabs: notes.setTabs, setToastMessage, onAfterSave,
|
||||
onNotePersisted: vault.clearUnsaved,
|
||||
})
|
||||
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
|
||||
|
||||
// Refs for stable closure in flushBeforeAction (avoids re-creating on every tab/content change)
|
||||
const tabsRef = useRef(notes.tabs)
|
||||
tabsRef.current = notes.tabs // eslint-disable-line react-hooks/refs -- ref sync pattern
|
||||
const unsavedPathsRef = useRef(vault.unsavedPaths)
|
||||
unsavedPathsRef.current = vault.unsavedPaths // eslint-disable-line react-hooks/refs -- ref sync pattern
|
||||
|
||||
/** Auto-save unsaved editor content before a destructive action (trash/archive). */
|
||||
const { clearUnsaved: vaultClearUnsaved } = vault
|
||||
const flushBeforeAction = useCallback(async (path: string) => {
|
||||
try {
|
||||
await flushEditorContent(path, {
|
||||
savePendingForPath,
|
||||
getTabContent: (p) => tabsRef.current.find(t => t.entry.path === p)?.content,
|
||||
isUnsaved: (p) => unsavedPathsRef.current.has(p),
|
||||
onSaved: (p) => { vaultClearUnsaved(p) },
|
||||
})
|
||||
} catch (err) {
|
||||
setToastMessage(`Auto-save failed: ${err}`)
|
||||
throw err
|
||||
}
|
||||
}, [savePendingForPath, vaultClearUnsaved, setToastMessage])
|
||||
|
||||
// Wire conflict file opener now that notes is available
|
||||
useEffect(() => {
|
||||
openConflictFileRef.current = (relativePath: string) => {
|
||||
@@ -314,7 +345,7 @@ function App() {
|
||||
notes.handleSelectNote(entry)
|
||||
dialogs.closeConflictResolver()
|
||||
} else {
|
||||
// Non-note file (e.g. .laputa-cache.json, settings.json) —
|
||||
// Non-note file (e.g. settings.json) —
|
||||
// open with system default app so the user can inspect/edit it
|
||||
openLocalFile(fullPath)
|
||||
}
|
||||
@@ -338,6 +369,7 @@ function App() {
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
onBeforeAction: flushBeforeAction,
|
||||
})
|
||||
|
||||
const handleDeleteNote = useCallback(async (path: string) => {
|
||||
@@ -432,7 +464,7 @@ function App() {
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
|
||||
entries: vault.entries, allContent: vault.allContent,
|
||||
entries: vault.entries,
|
||||
modifiedCount: vault.modifiedFiles.length,
|
||||
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
||||
selection,
|
||||
@@ -485,6 +517,7 @@ function App() {
|
||||
mcpStatus,
|
||||
onInstallMcp: installMcp,
|
||||
onReindexVault: indexing.triggerFullReindex,
|
||||
onReloadVault: vault.reloadVault,
|
||||
onRepairVault: handleRepairVault,
|
||||
})
|
||||
|
||||
@@ -549,7 +582,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -574,7 +607,6 @@ function App() {
|
||||
onInspectorResize={layout.handleInspectorResize}
|
||||
inspectorEntry={activeTab?.entry ?? null}
|
||||
inspectorContent={activeTab?.content ?? null}
|
||||
allContent={vault.allContent}
|
||||
gitHistory={gitHistory}
|
||||
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
|
||||
onDeleteProperty={notes.handleDeleteProperty}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { MarkdownContent } from './MarkdownContent'
|
||||
|
||||
interface AIChatPanelProps {
|
||||
entry: VaultEntry | null
|
||||
allContent: Record<string, string>
|
||||
entries?: VaultEntry[]
|
||||
onClose: () => void
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
@@ -177,17 +176,17 @@ function useContextNotes(entry: VaultEntry | null) {
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function AIChatPanel({ entry, allContent, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
|
||||
export function AIChatPanel({ entry, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const ctx = useContextNotes(entry)
|
||||
const chat = useAIChat(allContent, ctx.contextNotes)
|
||||
const chat = useAIChat(ctx.contextNotes)
|
||||
|
||||
const contextInfo = useMemo(
|
||||
() => buildSystemPrompt(ctx.contextNotes, allContent),
|
||||
[ctx.contextNotes, allContent],
|
||||
() => buildSystemPrompt(ctx.contextNotes),
|
||||
[ctx.contextNotes],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('AiPanel', () => {
|
||||
it('renders contextual empty state when active entry is provided', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
expect(screen.getByText('Ask about this note and its linked context')).toBeTruthy()
|
||||
})
|
||||
@@ -89,7 +89,7 @@ describe('AiPanel', () => {
|
||||
it('shows context bar with active entry title', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
expect(screen.getByTestId('context-bar')).toBeTruthy()
|
||||
expect(screen.getByText('My Note')).toBeTruthy()
|
||||
@@ -102,8 +102,7 @@ describe('AiPanel', () => {
|
||||
<AiPanel
|
||||
onClose={vi.fn()} vaultPath="/tmp/vault"
|
||||
activeEntry={entry} entries={[entry, linked]}
|
||||
allContent={{}}
|
||||
/>
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('+ 1 linked')).toBeTruthy()
|
||||
})
|
||||
@@ -129,7 +128,7 @@ describe('AiPanel', () => {
|
||||
it('shows contextual placeholder when active entry exists', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} allContent={{}} />
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask about this note...')
|
||||
|
||||
@@ -16,8 +16,9 @@ interface AiPanelProps {
|
||||
onVaultChanged?: () => void
|
||||
vaultPath: string
|
||||
activeEntry?: VaultEntry | null
|
||||
/** Direct content of the active note from the editor tab. */
|
||||
activeNoteContent?: string | null
|
||||
entries?: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
openTabs?: VaultEntry[]
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
@@ -110,7 +111,7 @@ function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, ha
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, activeNoteContent, entries, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -122,17 +123,17 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, on
|
||||
}, [activeEntry, entries])
|
||||
|
||||
const contextPrompt = useMemo(() => {
|
||||
if (!activeEntry || !allContent || !entries) return undefined
|
||||
if (!activeEntry || !entries) return undefined
|
||||
return buildContextSnapshot({
|
||||
activeEntry,
|
||||
allContent,
|
||||
activeNoteContent: activeNoteContent ?? undefined,
|
||||
openTabs,
|
||||
noteList,
|
||||
noteListFilter,
|
||||
entries,
|
||||
references: pendingRefs.length > 0 ? pendingRefs : undefined,
|
||||
})
|
||||
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
}, [activeEntry, activeNoteContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
|
||||
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
|
||||
@@ -130,7 +130,43 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips aliases and relationship keys', () => {
|
||||
it('renders capitalized Owner with plain text value in Properties panel', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Owner: 'Luca' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument()
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Owner with wikilink value from Properties panel', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Owner: '[[person/luca]]' }}
|
||||
/>
|
||||
)
|
||||
// Owner with wikilink goes to RelationshipsPanel, not Properties
|
||||
expect(screen.queryByText('Owner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders notion_id as a visible property', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ notion_id: 'abc-123-def' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('notion_id')).toBeInTheDocument()
|
||||
expect(screen.getByText('abc-123-def')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips aliases and fields with wikilink values', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -138,12 +174,37 @@ describe('DynamicPropertiesPanel', () => {
|
||||
frontmatter={{ aliases: ['AL'], 'Belongs to': '[[Something]]', cadence: 'Monthly' }}
|
||||
/>
|
||||
)
|
||||
// aliases and "Belongs to" should be skipped
|
||||
// aliases skipped (in SKIP_KEYS); 'Belongs to' skipped (has wikilinks)
|
||||
expect(screen.queryByText('aliases')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Belongs to')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('cadence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows former relationship key with plain text value in Properties', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ 'Belongs to': 'some-team', cadence: 'Monthly' }}
|
||||
/>
|
||||
)
|
||||
// 'Belongs to' has a plain text value, not a wikilink — should render as property
|
||||
expect(screen.getByText('Belongs to')).toBeInTheDocument()
|
||||
expect(screen.getByText('some-team')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides custom field with wikilink value from Properties', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Mentor: '[[person/luca]]' }}
|
||||
/>
|
||||
)
|
||||
// Mentor contains a wikilink → shown in Relationships, not Properties
|
||||
expect(screen.queryByText('Mentor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips is_a, Is A, and type keys (shown via TypeRow instead)', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
|
||||
@@ -27,12 +27,6 @@ import { TagsDropdown } from './TagsDropdown'
|
||||
import { getTagStyle } from '../utils/tagStyles'
|
||||
import { ColorEditableValue } from './ColorInput'
|
||||
|
||||
// Keys that are relationships (contain wikilinks)
|
||||
export const RELATIONSHIP_KEYS = new Set([
|
||||
'Belongs to', 'Related to', 'Events', 'Has Data', 'Owner',
|
||||
'Advances', 'Parent', 'Children', 'Has', 'Notes',
|
||||
])
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||
export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value)
|
||||
@@ -658,14 +652,13 @@ function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: n
|
||||
}
|
||||
|
||||
export function DynamicPropertiesPanel({
|
||||
entry, content, frontmatter, entries, allContent,
|
||||
entry, content, frontmatter, entries,
|
||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
content: string | null
|
||||
frontmatter: ParsedFrontmatter
|
||||
entries?: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
@@ -675,7 +668,7 @@ export function DynamicPropertiesPanel({
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
|
||||
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
||||
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty })
|
||||
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
|
||||
|
||||
const wordCount = countWords(content ?? '')
|
||||
|
||||
|
||||
@@ -105,7 +105,6 @@ const defaultProps = {
|
||||
onInspectorResize: vi.fn(),
|
||||
inspectorEntry: null as VaultEntry | null,
|
||||
inspectorContent: null as string | null,
|
||||
allContent: {} as Record<string, string>,
|
||||
gitHistory: [],
|
||||
onCreateNote: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect, useCallback, memo, useMemo } from 'react'
|
||||
import { useRef, useEffect, useCallback, memo } from 'react'
|
||||
import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap'
|
||||
import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
@@ -15,7 +15,6 @@ import { useEditorFocus } from '../hooks/useEditorFocus'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
import { schema } from './editorSchema'
|
||||
import { mergeTabContent } from '../utils/mergeTabContent'
|
||||
import './Editor.css'
|
||||
import './EditorTheme.css'
|
||||
|
||||
@@ -42,7 +41,6 @@ interface EditorProps {
|
||||
onInspectorResize: (delta: number) => void
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
allContent: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
onUpdateFrontmatter?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
|
||||
onDeleteProperty?: (path: string, key: string) => Promise<void>
|
||||
@@ -121,7 +119,7 @@ export const Editor = memo(function Editor({
|
||||
tabs, activeTabPath, entries, onSwitchTab, onCloseTab, onReorderTabs, onNavigateWikilink,
|
||||
onLoadDiff, onLoadDiffAtCommit, getNoteStatus, onCreateNote,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth, onInspectorResize,
|
||||
inspectorEntry, inspectorContent, allContent, gitHistory,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
@@ -173,11 +171,6 @@ export const Editor = memo(function Editor({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
|
||||
})
|
||||
|
||||
const enrichedAllContent = useMemo(
|
||||
() => mergeTabContent(allContent, tabs),
|
||||
[allContent, tabs],
|
||||
)
|
||||
|
||||
const isLoadingNewTab = activeTabPath !== null && !activeTab
|
||||
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
|
||||
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
|
||||
@@ -240,7 +233,6 @@ export const Editor = memo(function Editor({
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={enrichedAllContent}
|
||||
gitHistory={gitHistory}
|
||||
vaultPath={vaultPath ?? ''}
|
||||
openTabs={tabs.map(t => t.entry)}
|
||||
|
||||
@@ -10,7 +10,6 @@ interface EditorRightPanelProps {
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
entries: VaultEntry[]
|
||||
allContent: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
vaultPath: string
|
||||
openTabs?: VaultEntry[]
|
||||
@@ -31,7 +30,7 @@ interface EditorRightPanelProps {
|
||||
|
||||
export function EditorRightPanel({
|
||||
showAIChat, inspectorCollapsed, inspectorWidth,
|
||||
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath, openTabs,
|
||||
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath, openTabs,
|
||||
noteList, noteListFilter,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
|
||||
@@ -51,8 +50,8 @@ export function EditorRightPanel({
|
||||
onVaultChanged={onVaultChanged}
|
||||
vaultPath={vaultPath}
|
||||
activeEntry={inspectorEntry}
|
||||
activeNoteContent={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
openTabs={openTabs}
|
||||
noteList={noteList}
|
||||
noteListFilter={noteListFilter}
|
||||
@@ -74,7 +73,6 @@ export function EditorRightPanel({
|
||||
entry={inspectorEntry}
|
||||
content={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
gitHistory={gitHistory}
|
||||
onNavigate={onNavigateWikilink}
|
||||
onViewCommitDiff={onViewCommitDiff}
|
||||
|
||||
@@ -543,7 +543,7 @@ Status: Active
|
||||
<Inspector
|
||||
{...defaultProps}
|
||||
entry={typeEntry}
|
||||
content="---\nIs A: Type\n---\n# Responsibility\n"
|
||||
content="---\ntype: Type\n---\n# Responsibility\n"
|
||||
entries={[typeEntry, essayEntry]}
|
||||
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,8 @@ import { cn } from '@/lib/utils'
|
||||
import { SlidersHorizontal, X } from '@phosphor-icons/react'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||
import { wikilinkTarget } from '../utils/wikilink'
|
||||
import { extractBacklinkContext } from '../utils/wikilinks'
|
||||
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
|
||||
|
||||
export type FrontmatterValue = string | number | boolean | string[] | null
|
||||
@@ -18,7 +17,6 @@ interface InspectorProps {
|
||||
entry: VaultEntry | null
|
||||
content: string | null
|
||||
entries: VaultEntry[]
|
||||
allContent?: Record<string, string>
|
||||
gitHistory: GitCommit[]
|
||||
onNavigate: (target: string) => void
|
||||
onViewCommitDiff?: (commitHash: string) => void
|
||||
@@ -31,7 +29,6 @@ function useBacklinks(
|
||||
entry: VaultEntry | null,
|
||||
entries: VaultEntry[],
|
||||
referencedBy: ReferencedByItem[],
|
||||
allContent?: Record<string, string>,
|
||||
): BacklinkItem[] {
|
||||
return useMemo(() => {
|
||||
if (!entry) return []
|
||||
@@ -53,11 +50,9 @@ function useBacklinks(
|
||||
})
|
||||
.map((e) => ({
|
||||
entry: e,
|
||||
context: allContent?.[e.path]
|
||||
? extractBacklinkContext(allContent[e.path], matchTargets)
|
||||
: null,
|
||||
context: null,
|
||||
}))
|
||||
}, [entry, entries, referencedBy, allContent])
|
||||
}, [entry, entries, referencedBy])
|
||||
}
|
||||
|
||||
function refsMatchTargets(refs: string[], targets: Set<string>): boolean {
|
||||
@@ -118,11 +113,11 @@ function EmptyInspector() {
|
||||
}
|
||||
|
||||
export function Inspector({
|
||||
collapsed, onToggle, entry, content, entries, allContent, gitHistory, onNavigate,
|
||||
collapsed, onToggle, entry, content, entries, gitHistory, onNavigate,
|
||||
onViewCommitDiff, onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
}: InspectorProps) {
|
||||
const referencedBy = useReferencedBy(entry, entries)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy, allContent)
|
||||
const backlinks = useBacklinks(entry, entries, referencedBy)
|
||||
const frontmatter = useMemo(() => parseFrontmatter(content), [content])
|
||||
const typeEntryMap = useMemo(() => {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
@@ -151,7 +146,7 @@ export function Inspector({
|
||||
<>
|
||||
<DynamicPropertiesPanel
|
||||
entry={entry} content={content} frontmatter={frontmatter}
|
||||
entries={entries} allContent={allContent}
|
||||
entries={entries}
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
onAddProperty={onAddProperty ? handleAddProperty : undefined}
|
||||
@@ -163,6 +158,7 @@ export function Inspector({
|
||||
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
|
||||
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
|
||||
/>
|
||||
<InstancesPanel entry={entry} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<ReferencedByPanel items={referencedBy} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<BacklinksPanel backlinks={backlinks} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel } from './InspectorPanels'
|
||||
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
|
||||
import type { ReferencedByItem } from './InspectorPanels'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
|
||||
@@ -568,3 +568,112 @@ describe('GitHistoryPanel', () => {
|
||||
expect(screen.getByText('10d ago')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('InstancesPanel', () => {
|
||||
const onNavigate = vi.fn()
|
||||
const quarterType = makeEntry({
|
||||
path: '/vault/type/quarter.md', filename: 'quarter.md', title: 'Quarter',
|
||||
isA: 'Type', color: 'blue', icon: 'calendar',
|
||||
})
|
||||
const typeEntryMap: Record<string, VaultEntry> = { Quarter: quarterType }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders nothing when entry is not a Type', () => {
|
||||
const entry = makeEntry({ title: 'Random Note', isA: 'Note' })
|
||||
const { container } = render(
|
||||
<InstancesPanel entry={entry} entries={[]} typeEntryMap={{}} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders nothing when Type has zero instances', () => {
|
||||
const { container } = render(
|
||||
<InstancesPanel entry={quarterType} entries={[]} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders instances of a Type sorted by modifiedAt descending', () => {
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
|
||||
makeEntry({ path: '/vault/quarter/q2.md', title: 'Q2 2026', isA: 'Quarter', modifiedAt: 3000 }),
|
||||
makeEntry({ path: '/vault/quarter/q3.md', title: 'Q3 2026', isA: 'Quarter', modifiedAt: 2000 }),
|
||||
]
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(screen.getByText('Instances (3)')).toBeInTheDocument()
|
||||
const buttons = screen.getAllByRole('button').filter(b => ['Q1 2026', 'Q2 2026', 'Q3 2026'].includes(b.textContent?.replace(/\s*\(.*\)/, '') ?? ''))
|
||||
// Q2 (3000) should come before Q3 (2000) before Q1 (1000)
|
||||
expect(buttons[0].textContent).toContain('Q2 2026')
|
||||
expect(buttons[1].textContent).toContain('Q3 2026')
|
||||
expect(buttons[2].textContent).toContain('Q1 2026')
|
||||
})
|
||||
|
||||
it('excludes trashed instances', () => {
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 2000 }),
|
||||
makeEntry({ path: '/vault/quarter/q2.md', title: 'Q2 Trashed', isA: 'Quarter', trashed: true, modifiedAt: 3000 }),
|
||||
]
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(screen.getByText('Q1 2026')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Q2 Trashed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dims archived instances', () => {
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/old.md', title: 'Q4 2024', isA: 'Quarter', archived: true, modifiedAt: 1000 }),
|
||||
]
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(screen.getByTitle('Archived')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates when clicking an instance', () => {
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
|
||||
]
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Q1 2026'))
|
||||
expect(onNavigate).toHaveBeenCalledWith('Q1 2026')
|
||||
})
|
||||
|
||||
it('caps display at 50 instances and shows count badge', () => {
|
||||
const instances = Array.from({ length: 80 }, (_, i) =>
|
||||
makeEntry({
|
||||
path: `/vault/quarter/q${i}.md`,
|
||||
title: `Instance ${i}`,
|
||||
isA: 'Quarter',
|
||||
modifiedAt: 80 - i,
|
||||
})
|
||||
)
|
||||
render(
|
||||
<InstancesPanel entry={quarterType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(screen.getByText('Instances (80)')).toBeInTheDocument()
|
||||
// Only 50 link buttons rendered
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const instanceButtons = allButtons.filter(b => b.textContent?.startsWith('Instance'))
|
||||
expect(instanceButtons.length).toBe(50)
|
||||
expect(screen.getByText('showing 50 of 80')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show Instances section for non-Type note even if title matches a type name', () => {
|
||||
const notAType = makeEntry({ title: 'Quarter', isA: 'Project' })
|
||||
const instances = [
|
||||
makeEntry({ path: '/vault/quarter/q1.md', title: 'Q1 2026', isA: 'Quarter', modifiedAt: 1000 }),
|
||||
]
|
||||
const { container } = render(
|
||||
<InstancesPanel entry={notAType} entries={instances} typeEntryMap={typeEntryMap} onNavigate={onNavigate} />
|
||||
)
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,3 +4,4 @@ export type { BacklinkItem } from './inspector/BacklinksPanel'
|
||||
export { ReferencedByPanel } from './inspector/ReferencedByPanel'
|
||||
export type { ReferencedByItem } from './inspector/ReferencedByPanel'
|
||||
export { GitHistoryPanel } from './inspector/GitHistoryPanel'
|
||||
export { InstancesPanel } from './inspector/InstancesPanel'
|
||||
|
||||
@@ -153,38 +153,38 @@ const mockEntries: VaultEntry[] = [
|
||||
|
||||
describe('NoteList', () => {
|
||||
it('shows empty state when no entries', () => {
|
||||
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={[]} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('No notes found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all entries with All Notes filter', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by People (section group)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Person' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by Events (section group)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Event' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters by section group type', () => {
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entity pinned at top with grouped children', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Entity title appears in header and pinned card
|
||||
expect(screen.getAllByText('Build Laputa App').length).toBeGreaterThanOrEqual(1)
|
||||
@@ -199,7 +199,7 @@ describe('NoteList', () => {
|
||||
|
||||
it('filters by topic (relatedTo references)', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'topic', entry: mockEntries[4] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Build Laputa App has relatedTo: [[topic/software-development]]
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
@@ -207,7 +207,7 @@ describe('NoteList', () => {
|
||||
})
|
||||
|
||||
it('shows search input when search icon is clicked', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Search is hidden by default
|
||||
expect(screen.queryByPlaceholderText('Search notes...')).not.toBeInTheDocument()
|
||||
// Click search icon to show it
|
||||
@@ -216,7 +216,7 @@ describe('NoteList', () => {
|
||||
})
|
||||
|
||||
it('filters by search query (case-insensitive substring)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Open search
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
const input = screen.getByPlaceholderText('Search notes...')
|
||||
@@ -231,31 +231,31 @@ describe('NoteList', () => {
|
||||
{ ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
|
||||
{ ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
|
||||
]
|
||||
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithDifferentDates} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
const titles = screen.getAllByText(/Oldest|Newest|Middle/)
|
||||
const titleTexts = titles.map((el) => el.textContent)
|
||||
expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
|
||||
})
|
||||
|
||||
it('does not render type badge or status on note items', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Type badges like "Project", "Note" etc. should not appear as separate badge elements
|
||||
// The word "Project" should only appear in the ALL CAPS pill "PROJECTS 1", not as a standalone badge
|
||||
expect(screen.queryByText('Active')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('header shows search and plus icons instead of count badge', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByTitle('Search notes')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Create new note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('context view shows backlinks from allContent', () => {
|
||||
const allContent = {
|
||||
[mockEntries[2].path]: 'Met with [[project/26q1-laputa-app]] team.',
|
||||
}
|
||||
it('context view shows backlinks from outgoingLinks', () => {
|
||||
const entriesWithBacklink = mockEntries.map(e =>
|
||||
e.path === mockEntries[2].path ? { ...e, outgoingLinks: ['Build Laputa App'] } : e
|
||||
)
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={allContent} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithBacklink} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Backlinks')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
@@ -263,7 +263,7 @@ describe('NoteList', () => {
|
||||
|
||||
it('context view collapses and expands groups', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Children group is expanded by default
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
@@ -278,7 +278,7 @@ describe('NoteList', () => {
|
||||
|
||||
it('context view shows prominent card with snippet subtitle', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Snippet text appears in the prominent card
|
||||
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
|
||||
@@ -292,21 +292,21 @@ describe('NoteList click behavior', () => {
|
||||
})
|
||||
|
||||
it('regular click calls onReplaceActiveTab (opens in current tab)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+Click calls onSelectNote (opens in new tab)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Ctrl+Click calls onSelectNote (Windows/Linux)', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
|
||||
expect(noopSelect).toHaveBeenCalledWith(mockEntries[0])
|
||||
expect(noopReplace).not.toHaveBeenCalled()
|
||||
@@ -314,7 +314,7 @@ describe('NoteList click behavior', () => {
|
||||
|
||||
it('Cmd+Click on entity pinned card calls onSelectNote', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
fireEvent.click(titles[titles.length - 1], { metaKey: true })
|
||||
@@ -324,7 +324,7 @@ describe('NoteList click behavior', () => {
|
||||
|
||||
it('regular click on entity pinned card calls onReplaceActiveTab', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Title appears in both header and pinned card — use getAllByText and click the pinned card instance
|
||||
const titles = screen.getAllByText('Build Laputa App')
|
||||
@@ -335,7 +335,7 @@ describe('NoteList click behavior', () => {
|
||||
|
||||
it('click on child note in entity view calls onReplaceActiveTab', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
|
||||
expect(noopReplace).toHaveBeenCalledWith(mockEntries[1])
|
||||
@@ -489,21 +489,21 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('shows sort button in note list header for flat view', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('sort-button-__list__')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows sort dropdown per relationship subsection in entity view', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={{ kind: 'entity', entry: mockEntries[0] }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByTestId('sort-button-Children')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens sort menu on click and shows all options', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
||||
@@ -520,7 +520,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default sort: by modified (Zebra first)
|
||||
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
||||
@@ -537,7 +537,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('closes sort menu after selecting an option', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.getByTestId('sort-menu-__list__')).toBeInTheDocument()
|
||||
@@ -547,7 +547,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('shows direction arrows in sort dropdown menu', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
// Each option should have asc and desc direction buttons
|
||||
@@ -564,7 +564,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default sort: modified descending (Zebra first at 3000)
|
||||
let titles = screen.getAllByText(/Zebra|Alpha|Middle/).map((el) => el.textContent)
|
||||
@@ -585,7 +585,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/b.md', title: 'Alpha', modifiedAt: 1000 }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Select title sort with desc direction
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
@@ -598,7 +598,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('shows direction icon on the sort button that reflects current direction', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default: modified desc → should have ArrowDown icon
|
||||
expect(screen.getByTestId('sort-direction-icon-__list__')).toBeInTheDocument()
|
||||
@@ -635,7 +635,7 @@ describe('NoteList sort controls', () => {
|
||||
const entries = [parent, child1, child2]
|
||||
|
||||
render(
|
||||
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={{ kind: 'entity', entry: parent }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
|
||||
// Default sort: by modified — Zebra Note (3000) before Alpha Note (1000)
|
||||
@@ -657,7 +657,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/b.md', title: 'B', properties: { Priority: 'Low', Company: 'Acme' } }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.getByTestId('sort-separator')).toBeInTheDocument()
|
||||
@@ -668,7 +668,7 @@ describe('NoteList sort controls', () => {
|
||||
|
||||
it('omits separator when no custom properties exist', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
expect(screen.queryByTestId('sort-separator')).not.toBeInTheDocument()
|
||||
@@ -681,7 +681,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Rating: 5 } }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default: modified desc → A, B, C
|
||||
let titles = screen.getAllByText(/^[ABC]$/).map((el) => el.textContent)
|
||||
@@ -703,7 +703,7 @@ describe('NoteList sort controls', () => {
|
||||
makeEntry({ path: '/c.md', title: 'C', modifiedAt: 1000, properties: { Priority: 'Low' } }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('sort-button-__list__'))
|
||||
fireEvent.click(screen.getByTestId('sort-option-property:Priority'))
|
||||
@@ -821,7 +821,7 @@ describe('NoteList — status indicators', () => {
|
||||
it('shows modified indicator dot for modified notes', () => {
|
||||
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'modified' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const indicators = screen.getAllByTestId('modified-indicator')
|
||||
expect(indicators).toHaveLength(1)
|
||||
@@ -832,7 +832,7 @@ describe('NoteList — status indicators', () => {
|
||||
it('does not show indicator when all notes are clean', () => {
|
||||
const getNoteStatus = () => 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
|
||||
@@ -842,14 +842,14 @@ describe('NoteList — status indicators', () => {
|
||||
const modifiedPaths = new Set([mockEntries[0].path, mockEntries[1].path])
|
||||
const getNoteStatus = (path: string) => modifiedPaths.has(path) ? 'modified' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getAllByTestId('modified-indicator')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show indicator when getNoteStatus prop is undefined', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-indicator')).not.toBeInTheDocument()
|
||||
@@ -858,7 +858,7 @@ describe('NoteList — status indicators', () => {
|
||||
it('shows green new indicator for new notes', () => {
|
||||
const getNoteStatus = (path: string) => path === mockEntries[0].path ? 'new' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} getNoteStatus={getNoteStatus} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getAllByTestId('new-indicator')).toHaveLength(1)
|
||||
expect(screen.queryByTestId('modified-indicator')).not.toBeInTheDocument()
|
||||
@@ -869,31 +869,31 @@ describe('NoteList — trash view', () => {
|
||||
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }
|
||||
|
||||
it('shows "Trash" header when trash filter is active', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only trashed entries in trash view', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Old Draft Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows TRASHED badge on trashed entries', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
const badges = screen.getAllByText('TRASHED')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows 30-day warning banner when expired notes exist', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Trash is empty" when no trashed entries', () => {
|
||||
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash is empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -933,7 +933,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
it('renders 9000 entries without crashing', { timeout: 30000 }, () => {
|
||||
const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i))
|
||||
const { container } = render(
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Virtuoso mock renders all items; the real component only renders visible ones
|
||||
expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument()
|
||||
@@ -942,7 +942,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
it('renders items from a large dataset via Virtuoso', () => {
|
||||
const largeDataset = Array.from({ length: 500 }, (_, i) => makeEntry(i))
|
||||
render(
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Note 0')).toBeInTheDocument()
|
||||
expect(screen.getByText('Note 499')).toBeInTheDocument()
|
||||
@@ -955,7 +955,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
makeEntry(999, { title: 'Beta Strategy' }),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Search notes'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } })
|
||||
@@ -971,7 +971,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
...Array.from({ length: 100 }, (_, i) => makeEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Default sort is modified desc — Alpha (3000) should come first
|
||||
const firstTitle = screen.getAllByText(/^Alpha$|^Zebra$/)[0]
|
||||
@@ -984,7 +984,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
...Array.from({ length: 200 }, (_, i) => makeEntry(100 + i, { isA: 'Note', title: `Note ${i}` })),
|
||||
]
|
||||
render(
|
||||
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Project 0')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Note 0')).not.toBeInTheDocument()
|
||||
@@ -994,7 +994,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
|
||||
const selected = entries[5]
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={selected} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Note 5')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1003,7 +1003,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
noopReplace.mockClear()
|
||||
const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i))
|
||||
render(
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
fireEvent.click(screen.getByText('Note 50'))
|
||||
expect(noopReplace).toHaveBeenCalledWith(entries[50])
|
||||
@@ -1018,7 +1018,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
|
||||
it('shows only modified notes in changes view', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
@@ -1028,21 +1028,21 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
|
||||
it('shows header title "Changes"', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows empty state when no modified files', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('No pending changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates list when modifiedFiles changes', () => {
|
||||
const { rerender } = render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
@@ -1050,7 +1050,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
// Simulate one file being committed (removed from modifiedFiles)
|
||||
const fewerModified = [modifiedFiles[0]]
|
||||
rerender(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={fewerModified} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
|
||||
@@ -1061,7 +1061,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
// The changes filter must use modifiedFiles for filtering even when getNoteStatus is present.
|
||||
const getNoteStatus = (path: string) => modifiedFiles.some((f) => f.path === path) ? 'modified' as const : 'clean' as const
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} getNoteStatus={getNoteStatus} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
|
||||
@@ -1079,7 +1079,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
|
||||
]
|
||||
render(
|
||||
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// Even though absolute paths differ, entries should match via relative path suffix
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
@@ -1089,7 +1089,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
|
||||
it('shows error message when modifiedFilesError is set', () => {
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/git status failed/)).toBeInTheDocument()
|
||||
@@ -1101,7 +1101,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
|
||||
]
|
||||
render(
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
|
||||
@@ -1119,7 +1119,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('Shift+Click selects a range of notes', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Regular click to set anchor
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
// Shift+Click to select range
|
||||
@@ -1130,7 +1130,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('regular click clears multi-select and opens note', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Select range via Shift+click
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
@@ -1142,7 +1142,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('Cmd+Click clears multi-select and opens in new tab', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
// Select range via Shift+click
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
@@ -1154,7 +1154,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('shows bulk action bar with correct count', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
@@ -1163,7 +1163,7 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it('bulk archive calls onBulkArchive and clears selection', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-archive-btn'))
|
||||
@@ -1173,7 +1173,7 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it('bulk trash calls onBulkTrash and clears selection', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.click(screen.getByTestId('bulk-trash-btn'))
|
||||
@@ -1182,7 +1182,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('clear button on bulk action bar clears selection', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
@@ -1193,7 +1193,7 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it('Cmd+E archives selected notes when multiselect is active', () => {
|
||||
const onBulkArchive = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkArchive={onBulkArchive} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
@@ -1204,7 +1204,7 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it('Cmd+Backspace trashes selected notes when multiselect is active', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
expect(screen.getByTestId('bulk-action-bar')).toBeInTheDocument()
|
||||
@@ -1215,7 +1215,7 @@ describe('NoteList — multi-select', () => {
|
||||
|
||||
it('Cmd+Delete trashes selected notes when multiselect is active', () => {
|
||||
const onBulkTrash = vi.fn()
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onBulkTrash={onBulkTrash} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
|
||||
fireEvent.keyDown(window, { key: 'Delete', metaKey: true })
|
||||
@@ -1224,7 +1224,7 @@ describe('NoteList — multi-select', () => {
|
||||
})
|
||||
|
||||
it('no bulk action bar when nothing is selected', () => {
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
render(<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />)
|
||||
expect(screen.queryByTestId('bulk-action-bar')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1269,7 +1269,7 @@ describe('NoteList — type note filtering', () => {
|
||||
|
||||
it('does not show type note PinnedCard when browsing a sectionGroup', () => {
|
||||
render(
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
// The type note snippet should NOT be visible (PinnedCard was removed)
|
||||
expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument()
|
||||
@@ -1279,7 +1279,7 @@ describe('NoteList — type note filtering', () => {
|
||||
|
||||
it('shows clickable header title that navigates to type note', () => {
|
||||
render(
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const headerLink = screen.getByTestId('type-header-link')
|
||||
expect(headerLink).toBeInTheDocument()
|
||||
@@ -1291,7 +1291,7 @@ describe('NoteList — type note filtering', () => {
|
||||
|
||||
it('header is not clickable when not viewing a type section', () => {
|
||||
render(
|
||||
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1300,7 +1300,7 @@ describe('NoteList — type note filtering', () => {
|
||||
describe('NoteList — traffic light padding when sidebar collapsed', () => {
|
||||
it('adds left padding to header when sidebarCollapsed is true', () => {
|
||||
const { container } = render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} sidebarCollapsed={true} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={true} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('80px')
|
||||
@@ -1308,7 +1308,7 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
|
||||
|
||||
it('does not add extra left padding when sidebarCollapsed is false', () => {
|
||||
const { container } = render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} sidebarCollapsed={false} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} sidebarCollapsed={false} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('')
|
||||
@@ -1316,7 +1316,7 @@ describe('NoteList — traffic light padding when sidebar collapsed', () => {
|
||||
|
||||
it('does not add extra left padding when sidebarCollapsed is not provided', () => {
|
||||
const { container } = render(
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
<NoteList entries={mockEntries} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
|
||||
)
|
||||
const header = container.querySelector('.h-\\[52px\\]') as HTMLElement
|
||||
expect(header.style.paddingLeft).toBe('')
|
||||
|
||||
@@ -25,7 +25,6 @@ interface NoteListProps {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
selectedNote: VaultEntry | null
|
||||
allContent: Record<string, string>
|
||||
modifiedFiles?: ModifiedFile[]
|
||||
modifiedFilesError?: string | null
|
||||
getNoteStatus?: (path: string) => NoteStatus
|
||||
@@ -241,7 +240,7 @@ function toggleSetMember<T>(set: Set<T>, member: T): Set<T> {
|
||||
// --- Data hooks ---
|
||||
|
||||
interface NoteListDataParams {
|
||||
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
|
||||
entries: VaultEntry[]; selection: SidebarSelection
|
||||
query: string; listSort: SortOption; listDirection: SortDirection
|
||||
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
|
||||
}
|
||||
@@ -261,7 +260,7 @@ function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection,
|
||||
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes])
|
||||
}
|
||||
|
||||
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
|
||||
function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
|
||||
|
||||
@@ -274,9 +273,9 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
|
||||
|
||||
const searchedGroups = useMemo(() => {
|
||||
if (!isEntityView) return []
|
||||
const groups = buildRelationshipGroups(selection.entry, entries, allContent)
|
||||
const groups = buildRelationshipGroups(selection.entry, entries)
|
||||
return filterGroupsByQuery(groups, query)
|
||||
}, [isEntityView, selection, entries, allContent, query])
|
||||
}, [isEntityView, selection, entries, query])
|
||||
|
||||
const expiredTrashCount = useMemo(
|
||||
() => isTrashView ? countExpiredTrash(searched) : 0,
|
||||
@@ -484,14 +483,14 @@ function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, getNot
|
||||
return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry })
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
|
||||
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
() => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
|
||||
@@ -62,7 +62,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
() => deduplicateByPath(entries.filter(e => !e.trashed).map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
|
||||
@@ -8,7 +8,7 @@ function makeThemeManager(overrides: Partial<ThemeManager> = {}): ThemeManager {
|
||||
themes: [],
|
||||
activeThemeId: '/vault/_themes/My Theme.md',
|
||||
activeTheme: { id: '/vault/_themes/My Theme.md', name: 'My Theme', description: '', colors: {}, typography: {}, spacing: {} },
|
||||
activeThemeContent: '---\nIs A: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
|
||||
activeThemeContent: '---\ntype: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
|
||||
isDark: false,
|
||||
switchTheme: vi.fn(),
|
||||
createTheme: vi.fn().mockResolvedValue(''),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useState, useRef, useMemo, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { bestSearchRank } from '../utils/fuzzyMatch'
|
||||
|
||||
const MAX_SUGGESTIONS = 20
|
||||
const MIN_QUERY_LENGTH = 1
|
||||
@@ -46,13 +47,16 @@ function matchEntries(
|
||||
): SuggestionEntry[] {
|
||||
if (query.length < MIN_QUERY_LENGTH) return []
|
||||
const lower = query.toLowerCase()
|
||||
const matches = entries.filter(e =>
|
||||
!e.trashed && !e.archived && (
|
||||
e.title.toLowerCase().includes(lower) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lower))
|
||||
),
|
||||
)
|
||||
return matches.slice(0, MAX_SUGGESTIONS).map(e => {
|
||||
const matches = entries
|
||||
.filter(e =>
|
||||
!e.trashed && !e.archived && (
|
||||
e.title.toLowerCase().includes(lower) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lower))
|
||||
),
|
||||
)
|
||||
.map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) }))
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return {
|
||||
title: e.title,
|
||||
|
||||
57
src/components/inspector/InstancesPanel.tsx
Normal file
57
src/components/inspector/InstancesPanel.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { getTypeColor } from '../../utils/typeColors'
|
||||
import { getTypeIcon } from '../NoteItem'
|
||||
import { LinkButton } from './LinkButton'
|
||||
import { entryStatusTitle } from './shared'
|
||||
|
||||
const MAX_DISPLAY = 50
|
||||
|
||||
export function InstancesPanel({ entry, entries, typeEntryMap, onNavigate }: {
|
||||
entry: VaultEntry
|
||||
entries: VaultEntry[]
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
onNavigate: (target: string) => void
|
||||
}) {
|
||||
const instances = useMemo(() => {
|
||||
if (entry.isA !== 'Type') return []
|
||||
return entries
|
||||
.filter((e) => e.isA === entry.title && !e.trashed)
|
||||
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
|
||||
}, [entry, entries])
|
||||
|
||||
if (instances.length === 0) return null
|
||||
|
||||
const displayed = instances.slice(0, MAX_DISPLAY)
|
||||
const total = instances.length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="font-mono-overline mb-1 block text-muted-foreground">
|
||||
Instances ({total})
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{displayed.map((e) => {
|
||||
const te = typeEntryMap[e.isA ?? '']
|
||||
return (
|
||||
<LinkButton
|
||||
key={e.path}
|
||||
label={e.title}
|
||||
typeColor={getTypeColor(e.isA, te?.color)}
|
||||
isArchived={e.archived}
|
||||
isTrashed={false}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={entryStatusTitle(e)}
|
||||
TypeIcon={getTypeIcon(e.isA, te?.icon)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{total > MAX_DISPLAY && (
|
||||
<span className="mt-1 block text-[11px] text-muted-foreground">
|
||||
showing {MAX_DISPLAY} of {total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useCallback, useState, useRef } from 'react'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import { X } from '@phosphor-icons/react'
|
||||
import type { ParsedFrontmatter } from '../../utils/frontmatter'
|
||||
import { RELATIONSHIP_KEYS, containsWikilinks } from '../DynamicPropertiesPanel'
|
||||
import { containsWikilinks } from '../DynamicPropertiesPanel'
|
||||
import type { FrontmatterValue } from '../Inspector'
|
||||
import { NoteSearchList } from '../NoteSearchList'
|
||||
import { useNoteSearch } from '../../hooks/useNoteSearch'
|
||||
@@ -123,7 +123,7 @@ function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onR
|
||||
|
||||
function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string; refs: string[] }[] {
|
||||
return Object.entries(frontmatter)
|
||||
.filter(([key, value]) => key !== 'Type' && (RELATIONSHIP_KEYS.has(key) || containsWikilinks(value)))
|
||||
.filter(([key, value]) => key !== 'Type' && containsWikilinks(value))
|
||||
.map(([key, value]) => {
|
||||
const refs: string[] = []
|
||||
if (typeof value === 'string' && isWikilink(value)) refs.push(value)
|
||||
|
||||
@@ -41,7 +41,6 @@ function renderNoteList(props: {
|
||||
entries={props.entries}
|
||||
selection={props.selection}
|
||||
selectedNote={null}
|
||||
allContent={{}}
|
||||
onSelectNote={noop}
|
||||
onReplaceActiveTab={noop}
|
||||
onCreateNote={noop}
|
||||
|
||||
88
src/extensions/zoomCursorFix.test.ts
Normal file
88
src/extensions/zoomCursorFix.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { getDocumentZoom, adjustCoordsForZoom } from './zoomCursorFix'
|
||||
|
||||
function mockComputedZoom(value: string) {
|
||||
const real = window.getComputedStyle.bind(window)
|
||||
return vi.spyOn(window, 'getComputedStyle').mockImplementation((elt, pseudo) => {
|
||||
const style = real(elt, pseudo)
|
||||
if (elt === document.documentElement) {
|
||||
return new Proxy(style, {
|
||||
get(target, prop) {
|
||||
if (prop === 'zoom') return value
|
||||
const val = Reflect.get(target, prop)
|
||||
return typeof val === 'function' ? val.bind(target) : val
|
||||
},
|
||||
})
|
||||
}
|
||||
return style
|
||||
})
|
||||
}
|
||||
|
||||
describe('getDocumentZoom', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns 1 when no zoom is set', () => {
|
||||
expect(getDocumentZoom()).toBe(1)
|
||||
})
|
||||
|
||||
it('returns the zoom factor when computed style reports a decimal', () => {
|
||||
const spy = mockComputedZoom('1.5')
|
||||
expect(getDocumentZoom()).toBe(1.5)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns the zoom factor for sub-100% zoom', () => {
|
||||
const spy = mockComputedZoom('0.8')
|
||||
expect(getDocumentZoom()).toBe(0.8)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns 1 for zoom: normal', () => {
|
||||
const spy = mockComputedZoom('normal')
|
||||
expect(getDocumentZoom()).toBe(1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns 1 for empty/missing zoom value', () => {
|
||||
const spy = mockComputedZoom('')
|
||||
expect(getDocumentZoom()).toBe(1)
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('adjustCoordsForZoom', () => {
|
||||
it('returns coords unchanged when zoom is 1', () => {
|
||||
expect(adjustCoordsForZoom({ x: 200, y: 100 }, 1)).toEqual({ x: 200, y: 100 })
|
||||
})
|
||||
|
||||
it('divides coords by zoom factor for zoom > 1', () => {
|
||||
const result = adjustCoordsForZoom({ x: 300, y: 150 }, 1.5)
|
||||
expect(result.x).toBe(200)
|
||||
expect(result.y).toBe(100)
|
||||
})
|
||||
|
||||
it('divides coords by zoom factor for zoom < 1', () => {
|
||||
const result = adjustCoordsForZoom({ x: 160, y: 80 }, 0.8)
|
||||
expect(result.x).toBe(200)
|
||||
expect(result.y).toBe(100)
|
||||
})
|
||||
|
||||
it('handles common zoom levels correctly', () => {
|
||||
// 90% zoom
|
||||
const at90 = adjustCoordsForZoom({ x: 90, y: 90 }, 0.9)
|
||||
expect(at90.x).toBeCloseTo(100, 10)
|
||||
expect(at90.y).toBeCloseTo(100, 10)
|
||||
|
||||
// 110% zoom
|
||||
const at110 = adjustCoordsForZoom({ x: 110, y: 110 }, 1.1)
|
||||
expect(at110.x).toBeCloseTo(100, 10)
|
||||
expect(at110.y).toBeCloseTo(100, 10)
|
||||
|
||||
// 125% zoom
|
||||
const at125 = adjustCoordsForZoom({ x: 125, y: 125 }, 1.25)
|
||||
expect(at125.x).toBeCloseTo(100, 10)
|
||||
expect(at125.y).toBeCloseTo(100, 10)
|
||||
})
|
||||
})
|
||||
156
src/extensions/zoomCursorFix.ts
Normal file
156
src/extensions/zoomCursorFix.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { EditorView, ViewPlugin } from '@codemirror/view'
|
||||
|
||||
/**
|
||||
* Read the current CSS zoom factor from document.documentElement.
|
||||
* Returns 1 when no zoom is applied or the value is unparseable.
|
||||
*
|
||||
* Checks getComputedStyle first (real browsers return the decimal value),
|
||||
* then falls back to the inline style property (works in jsdom and test
|
||||
* environments where getComputedStyle doesn't report zoom).
|
||||
*/
|
||||
export function getDocumentZoom(): number {
|
||||
const computed = getComputedStyle(document.documentElement).zoom
|
||||
if (computed && computed !== 'normal') {
|
||||
const parsed = parseFloat(computed)
|
||||
if (parsed > 0 && isFinite(parsed)) return parsed
|
||||
}
|
||||
|
||||
const inline = document.documentElement.style.getPropertyValue('zoom')
|
||||
if (inline && inline !== 'normal') {
|
||||
let value = parseFloat(inline)
|
||||
if (inline.endsWith('%')) value /= 100
|
||||
if (value > 0 && isFinite(value)) return value
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert viewport-space coordinates to CSS-space coordinates by
|
||||
* dividing by the zoom factor. When CSS zoom is applied to the root
|
||||
* element, mouse event clientX/clientY are in viewport space, but
|
||||
* Range.getClientRects() (used by CodeMirror's posAtCoords) may return
|
||||
* values in CSS space. Dividing by zoom aligns them.
|
||||
*/
|
||||
export function adjustCoordsForZoom(
|
||||
coords: { x: number; y: number },
|
||||
zoom: number,
|
||||
): { x: number; y: number } {
|
||||
if (zoom === 1) return coords
|
||||
return { x: coords.x / zoom, y: coords.y / zoom }
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the browser's native caretRangeFromPoint API to find the document
|
||||
* position at viewport coordinates. This API correctly handles CSS zoom
|
||||
* because it operates in the browser's own coordinate system.
|
||||
*
|
||||
* Returns null if the API is unavailable or the position is outside the
|
||||
* editor's content area.
|
||||
*/
|
||||
function caretPosFromPoint(
|
||||
view: EditorView,
|
||||
x: number,
|
||||
y: number,
|
||||
): number | null {
|
||||
if (typeof document.caretRangeFromPoint !== 'function') return null
|
||||
|
||||
const range = document.caretRangeFromPoint(x, y)
|
||||
if (!range) return null
|
||||
|
||||
if (!view.contentDOM.contains(range.startContainer)) return null
|
||||
|
||||
try {
|
||||
return view.posAtDOM(range.startContainer, range.startOffset)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type Coords = { x: number; y: number }
|
||||
type PosAndSide = { pos: number; assoc: -1 | 1 }
|
||||
|
||||
/**
|
||||
* CodeMirror extension that fixes cursor positioning at non-100% CSS zoom.
|
||||
*
|
||||
* When CSS `zoom` is applied to document.documentElement, CodeMirror's
|
||||
* posAtCoords breaks because it compares mouse event coordinates (viewport
|
||||
* space) against Range.getClientRects() values (which may be in CSS space
|
||||
* under zoom). This extension overrides posAtCoords and posAndSideAtCoords
|
||||
* on the EditorView instance with zoom-aware versions that:
|
||||
*
|
||||
* 1. Use document.caretRangeFromPoint() — the browser's native, zoom-aware
|
||||
* coordinate-to-text API — to find the correct position.
|
||||
* 2. Fall back to the original method with coordinates divided by the zoom
|
||||
* factor if caretRangeFromPoint is unavailable or returns no result.
|
||||
*/
|
||||
export function zoomCursorFix() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyFn = (...args: any[]) => any
|
||||
|
||||
return ViewPlugin.define((view) => {
|
||||
const origPosAtCoords: AnyFn =
|
||||
Object.getPrototypeOf(view).posAtCoords
|
||||
const origPosAndSideAtCoords: AnyFn =
|
||||
Object.getPrototypeOf(view).posAndSideAtCoords
|
||||
|
||||
function zoomPosAtCoords(
|
||||
self: EditorView,
|
||||
coords: Coords,
|
||||
precise?: boolean,
|
||||
): number | null {
|
||||
const zoom = getDocumentZoom()
|
||||
if (zoom === 1) return origPosAtCoords.call(self, coords, precise)
|
||||
|
||||
const pos = caretPosFromPoint(self, coords.x, coords.y)
|
||||
if (pos !== null) return pos
|
||||
|
||||
const adjusted = adjustCoordsForZoom(coords, zoom)
|
||||
return origPosAtCoords.call(self, adjusted, precise)
|
||||
}
|
||||
|
||||
function zoomPosAndSideAtCoords(
|
||||
self: EditorView,
|
||||
coords: Coords,
|
||||
precise?: boolean,
|
||||
): PosAndSide | null {
|
||||
const zoom = getDocumentZoom()
|
||||
if (zoom === 1)
|
||||
return origPosAndSideAtCoords.call(self, coords, precise)
|
||||
|
||||
const pos = caretPosFromPoint(self, coords.x, coords.y)
|
||||
if (pos !== null) return { pos, assoc: 1 }
|
||||
|
||||
const adjusted = adjustCoordsForZoom(coords, zoom)
|
||||
return origPosAndSideAtCoords.call(self, adjusted, precise)
|
||||
}
|
||||
|
||||
// Override on the instance (shadows prototype methods)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(view as any).posAtCoords = function (
|
||||
this: EditorView,
|
||||
coords: Coords,
|
||||
precise?: boolean,
|
||||
) {
|
||||
return zoomPosAtCoords(this, coords, precise)
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(view as any).posAndSideAtCoords = function (
|
||||
this: EditorView,
|
||||
coords: Coords,
|
||||
precise?: boolean,
|
||||
) {
|
||||
return zoomPosAndSideAtCoords(this, coords, precise)
|
||||
}
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
// Remove instance overrides, restoring prototype methods
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (view as any).posAtCoords
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (view as any).posAndSideAtCoords
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -13,14 +13,13 @@ vi.mock('../utils/ai-chat', async () => {
|
||||
...actual,
|
||||
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
|
||||
streamClaudeChatMock(...args)
|
||||
// Simulate async: emit Init with session_id, then text, then done
|
||||
// Simulate async: emit text, then done
|
||||
const callbacks = args[3]
|
||||
setTimeout(() => {
|
||||
callbacks.onInit?.('session-001')
|
||||
callbacks.onText('mock response')
|
||||
callbacks.onDone()
|
||||
}, 10)
|
||||
return Promise.resolve('session-001')
|
||||
return Promise.resolve('')
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -37,64 +36,38 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('useAIChat', () => {
|
||||
const emptyContent: Record<string, string> = {}
|
||||
|
||||
it('sends first message without session_id (new session)', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
it('sends first message as raw text without history', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
|
||||
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
|
||||
// First message: raw text, no session_id
|
||||
// First message: raw text, no history wrapping, no session_id
|
||||
expect(message).toBe('hello')
|
||||
expect(sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resumes session on second message via --resume', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
it('embeds conversation history in second message', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('What is Rust?') })
|
||||
// Wait for mock response (which fires onInit with session-001)
|
||||
// First exchange
|
||||
act(() => { result.current.sendMessage('What is 2+2?') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
|
||||
// Send second message — should resume with session_id
|
||||
act(() => { result.current.sendMessage('Tell me more') })
|
||||
// Second message — should include history from first exchange
|
||||
act(() => { result.current.sendMessage('What is that times 3?') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
||||
const [message, systemPrompt, sessionId] = streamClaudeChatMock.mock.calls[1]
|
||||
// Raw message only (no embedded history)
|
||||
expect(message).toBe('Tell me more')
|
||||
// Session resumed via --resume
|
||||
expect(sessionId).toBe('session-001')
|
||||
// System prompt omitted on resumed sessions
|
||||
expect(systemPrompt).toBeUndefined()
|
||||
const [message] = streamClaudeChatMock.mock.calls[1]
|
||||
expect(message).toContain('<conversation_history>')
|
||||
expect(message).toContain('What is 2+2?')
|
||||
expect(message).toContain('mock response')
|
||||
expect(message).toContain('What is that times 3?')
|
||||
})
|
||||
|
||||
it('resets session on clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send a message and get response
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear conversation
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Send new message — should start a fresh session (no session_id)
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start')
|
||||
expect(lastCall[2]).toBeUndefined() // no session_id
|
||||
})
|
||||
|
||||
it('resumes session across multiple exchanges', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
it('accumulates history across multiple exchanges', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Exchange 1
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
@@ -109,43 +82,77 @@ describe('useAIChat', () => {
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
// First call: no session
|
||||
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
|
||||
// Second and third calls: resume session
|
||||
expect(streamClaudeChatMock.mock.calls[1][2]).toBe('session-001')
|
||||
expect(streamClaudeChatMock.mock.calls[2][2]).toBe('session-001')
|
||||
|
||||
// All messages are raw text (no embedded history)
|
||||
// First call: no history
|
||||
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
|
||||
expect(streamClaudeChatMock.mock.calls[1][0]).toBe('Q2')
|
||||
expect(streamClaudeChatMock.mock.calls[2][0]).toBe('Q3')
|
||||
// Second call: history from first exchange
|
||||
const secondMsg = streamClaudeChatMock.mock.calls[1][0]
|
||||
expect(secondMsg).toContain('Q1')
|
||||
expect(secondMsg).toContain('Q2')
|
||||
// Third call: history from both exchanges
|
||||
const thirdMsg = streamClaudeChatMock.mock.calls[2][0]
|
||||
expect(thirdMsg).toContain('Q1')
|
||||
expect(thirdMsg).toContain('Q2')
|
||||
expect(thirdMsg).toContain('Q3')
|
||||
})
|
||||
|
||||
it('includes system prompt only on first message of a session', async () => {
|
||||
const content = { 'note.md': 'Some note content' }
|
||||
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
|
||||
it('never passes session_id (no --resume)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
const { result } = renderHook(() => useAIChat(content, notes))
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
|
||||
// First message — system prompt included
|
||||
// All calls should have undefined session_id
|
||||
for (const call of streamClaudeChatMock.mock.calls) {
|
||||
expect(call[2]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets history after clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Build up some history
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Next message should have no history
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start') // raw text, no history wrapping
|
||||
expect(lastCall[2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes system prompt on every message when context notes exist', async () => {
|
||||
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
|
||||
|
||||
const { result } = renderHook(() => useAIChat(notes))
|
||||
|
||||
// First message
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message
|
||||
act(() => { result.current.sendMessage('follow up') })
|
||||
|
||||
// Both calls should have system prompt
|
||||
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
|
||||
expect(firstSystemPrompt).toBeTruthy()
|
||||
expect(firstSystemPrompt).toContain('Test Note')
|
||||
|
||||
// Second message — system prompt omitted (session already has it)
|
||||
act(() => { result.current.sendMessage('follow up') })
|
||||
|
||||
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
|
||||
expect(secondSystemPrompt).toBeUndefined()
|
||||
expect(secondSystemPrompt).toBeTruthy()
|
||||
expect(secondSystemPrompt).toContain('Test Note')
|
||||
})
|
||||
|
||||
it('resets session on retry', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
it('retries with correct history (excludes retried exchange)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Send message and get response
|
||||
// First exchange
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
@@ -153,9 +160,33 @@ describe('useAIChat', () => {
|
||||
// Retry the assistant response (index 1)
|
||||
act(() => { result.current.retryMessage(1) })
|
||||
|
||||
// Should start a fresh session
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[2]).toBeUndefined() // no session_id — fresh session
|
||||
expect(lastCall[0]).toBe('hello') // re-sends the user message
|
||||
// Should re-send the user message with no history (retrying first exchange)
|
||||
expect(lastCall[0]).toBe('hello')
|
||||
expect(lastCall[2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads latest messages from ref (not stale closure)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('msg1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Verify messages state is correct
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
expect(result.current.messages[0].content).toBe('msg1')
|
||||
expect(result.current.messages[1].content).toBe('mock response')
|
||||
|
||||
// Send second message — the ref should have the latest messages
|
||||
// even without depending on messages in useCallback deps
|
||||
act(() => { result.current.sendMessage('msg2') })
|
||||
|
||||
const secondCall = streamClaudeChatMock.mock.calls[1]
|
||||
const sentMessage = secondCall[0]
|
||||
// Must contain history from first exchange
|
||||
expect(sentMessage).toContain('[user]: msg1')
|
||||
expect(sentMessage).toContain('[assistant]: mock response')
|
||||
expect(sentMessage).toContain('[user]: msg2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,21 +2,23 @@
|
||||
* Custom hook encapsulating AI chat state and message handling.
|
||||
* Uses Claude CLI subprocess via Tauri for streaming responses.
|
||||
*
|
||||
* Conversation continuity uses the CLI's --resume flag: the first message
|
||||
* starts a new session; subsequent messages resume it via session_id.
|
||||
* This avoids embedding history as text in the prompt (which the CLI's
|
||||
* -p mode treats as a single user turn, losing turn boundaries).
|
||||
* Conversation continuity embeds prior exchanges in each prompt
|
||||
* (each CLI invocation is a fresh subprocess with no memory).
|
||||
* History is trimmed to MAX_HISTORY_TOKENS, dropping oldest first.
|
||||
*
|
||||
* Uses a ref (messagesRef) to read the latest messages in callbacks,
|
||||
* avoiding stale closure issues with React's useCallback memoization.
|
||||
*/
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
|
||||
buildSystemPrompt, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
|
||||
} from '../utils/ai-chat'
|
||||
|
||||
interface ChatStreamRefs {
|
||||
abortRef: React.RefObject<boolean>
|
||||
sessionIdRef: React.MutableRefObject<string | undefined>
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
|
||||
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
|
||||
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
|
||||
@@ -28,7 +30,6 @@ function makeStreamCallbacks(
|
||||
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
|
||||
let accumulated = ''
|
||||
const callbacks: ChatStreamCallbacks = {
|
||||
onInit: (sid) => { refs.sessionIdRef.current = sid },
|
||||
onText: (chunk) => {
|
||||
if (refs.abortRef.current) return
|
||||
accumulated += chunk
|
||||
@@ -53,58 +54,67 @@ function makeStreamCallbacks(
|
||||
}
|
||||
|
||||
export function useAIChat(
|
||||
allContent: Record<string, string>,
|
||||
contextNotes: VaultEntry[],
|
||||
) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
const sessionIdRef = useRef<string | undefined>(undefined)
|
||||
const isStreamingRef = useRef(false)
|
||||
const messagesRef = useRef<ChatMessage[]>([])
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
if (!text.trim() || isStreaming) return
|
||||
// Keep refs in sync with state — runs after render, before next user interaction.
|
||||
useEffect(() => { messagesRef.current = messages }, [messages])
|
||||
useEffect(() => { isStreamingRef.current = isStreaming }, [isStreaming])
|
||||
|
||||
/** Internal: send text, reading history from the messages ref. */
|
||||
const doSend = useCallback((text: string, historyOverride?: ChatMessage[]) => {
|
||||
if (!text.trim() || isStreamingRef.current) return
|
||||
|
||||
const history = historyOverride ?? messagesRef.current
|
||||
|
||||
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
|
||||
setIsStreaming(true)
|
||||
setStreamingContent('')
|
||||
abortRef.current = false
|
||||
|
||||
const currentSessionId = sessionIdRef.current
|
||||
// System prompt only on first message (new session).
|
||||
const systemPrompt = currentSessionId
|
||||
? undefined
|
||||
: (buildSystemPrompt(contextNotes, allContent).prompt || undefined)
|
||||
// Always include system prompt (each request is a fresh subprocess).
|
||||
const systemPrompt = buildSystemPrompt(contextNotes).prompt || undefined
|
||||
|
||||
// Embed conversation history in the prompt for continuity.
|
||||
const trimmedHistory = trimHistory(history, MAX_HISTORY_TOKENS)
|
||||
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
|
||||
|
||||
const { callbacks } = makeStreamCallbacks({
|
||||
abortRef, sessionIdRef, setMessages, setStreamingContent, setIsStreaming,
|
||||
abortRef, setMessages, setStreamingContent, setIsStreaming,
|
||||
})
|
||||
|
||||
streamClaudeChat(text.trim(), systemPrompt, currentSessionId, callbacks)
|
||||
.then((sid) => {
|
||||
if (sid && !sessionIdRef.current) sessionIdRef.current = sid
|
||||
})
|
||||
streamClaudeChat(formattedMessage, systemPrompt, undefined, callbacks)
|
||||
.catch(() => { /* errors forwarded via onError */ })
|
||||
}, [isStreaming, allContent, contextNotes])
|
||||
}, [contextNotes])
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
doSend(text)
|
||||
}, [doSend])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
setMessages([])
|
||||
setIsStreaming(false)
|
||||
setStreamingContent('')
|
||||
sessionIdRef.current = undefined
|
||||
}, [])
|
||||
|
||||
const retryMessage = useCallback((msgIndex: number) => {
|
||||
const currentMessages = messagesRef.current
|
||||
const userMsgIndex = msgIndex - 1
|
||||
if (userMsgIndex < 0) return
|
||||
const userMsg = messages[userMsgIndex]
|
||||
const userMsg = currentMessages[userMsgIndex]
|
||||
if (userMsg.role !== 'user') return
|
||||
|
||||
sessionIdRef.current = undefined
|
||||
setMessages(prev => prev.slice(0, msgIndex))
|
||||
sendMessage(userMsg.content)
|
||||
}, [messages, sendMessage])
|
||||
const historyForRetry = currentMessages.slice(0, userMsgIndex)
|
||||
setMessages(prev => prev.slice(0, userMsgIndex))
|
||||
doSend(userMsg.content, historyForRetry)
|
||||
}, [doSend])
|
||||
|
||||
return { messages, isStreaming, streamingContent, sendMessage, clearConversation, retryMessage }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { detectFileOperation, parseBashFileCreation } from './useAiAgent'
|
||||
import type { AgentFileCallbacks } from './useAiAgent'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { detectFileOperation, parseBashFileCreation, agentMessagesToChatHistory, useAiAgent } from './useAiAgent'
|
||||
import type { AgentFileCallbacks, AiAgentMessage } from './useAiAgent'
|
||||
import { streamClaudeAgent } from '../utils/ai-agent'
|
||||
|
||||
vi.mock('../utils/ai-agent', () => ({
|
||||
streamClaudeAgent: vi.fn(),
|
||||
buildAgentSystemPrompt: vi.fn(() => 'default-system-prompt'),
|
||||
}))
|
||||
|
||||
const mockStreamClaudeAgent = vi.mocked(streamClaudeAgent)
|
||||
|
||||
const VAULT = '/Users/luca/Laputa'
|
||||
|
||||
@@ -176,3 +185,182 @@ describe('parseBashFileCreation', () => {
|
||||
expect(parseBashFileCreation(input, VAULT)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAiAgent', () => {
|
||||
beforeEach(() => {
|
||||
mockStreamClaudeAgent.mockReset()
|
||||
mockStreamClaudeAgent.mockImplementation(async (_msg: string, _sys: string, _vault: string, callbacks: { onDone: () => void }) => {
|
||||
callbacks.onDone()
|
||||
})
|
||||
})
|
||||
|
||||
it('updates sendMessage when contextPrompt changes (no stale closure)', () => {
|
||||
// Mount with empty contextPrompt (simulates race: panel mounts before tab content loads)
|
||||
const { result, rerender } = renderHook(
|
||||
({ vault, context }) => useAiAgent(vault, context),
|
||||
{ initialProps: { vault: VAULT, context: undefined as string | undefined } },
|
||||
)
|
||||
|
||||
const firstSendMessage = result.current.sendMessage
|
||||
|
||||
// Simulate tab content arriving — re-render with real contextPrompt
|
||||
rerender({ vault: VAULT, context: 'You are viewing note with body: Hello world' })
|
||||
|
||||
// sendMessage must be a NEW function that closes over the updated contextPrompt.
|
||||
// If it's the same reference, it may read stale context (the race condition).
|
||||
expect(result.current.sendMessage).not.toBe(firstSendMessage)
|
||||
})
|
||||
|
||||
it('uses the current contextPrompt at send time, not the mount-time value', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ vault, context }) => useAiAgent(vault, context),
|
||||
{ initialProps: { vault: VAULT, context: undefined as string | undefined } },
|
||||
)
|
||||
|
||||
rerender({ vault: VAULT, context: 'You are viewing note with body: Hello world' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('What does this note contain?')
|
||||
})
|
||||
|
||||
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(1)
|
||||
expect(mockStreamClaudeAgent.mock.calls[0][1]).toBe('You are viewing note with body: Hello world')
|
||||
})
|
||||
|
||||
it('falls back to buildAgentSystemPrompt when contextPrompt is undefined', async () => {
|
||||
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('Hello')
|
||||
})
|
||||
|
||||
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(1)
|
||||
expect(mockStreamClaudeAgent.mock.calls[0][1]).toBe('default-system-prompt')
|
||||
})
|
||||
|
||||
it('sends first message without conversation history', async () => {
|
||||
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('Hello')
|
||||
})
|
||||
|
||||
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(1)
|
||||
const sentMessage = mockStreamClaudeAgent.mock.calls[0][0]
|
||||
expect(sentMessage).toBe('Hello')
|
||||
expect(sentMessage).not.toContain('<conversation_history>')
|
||||
})
|
||||
|
||||
it('embeds conversation history in second message', async () => {
|
||||
// Mock that simulates a response for the first message
|
||||
mockStreamClaudeAgent.mockImplementation(async (_msg, _sys, _vault, callbacks) => {
|
||||
callbacks.onText('The answer is 4')
|
||||
callbacks.onDone()
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
|
||||
|
||||
// First message
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('What is 2+2?')
|
||||
})
|
||||
|
||||
// Second message — should include history from first exchange
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('What was my previous question?')
|
||||
})
|
||||
|
||||
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(2)
|
||||
|
||||
// First call: no history
|
||||
expect(mockStreamClaudeAgent.mock.calls[0][0]).toBe('What is 2+2?')
|
||||
|
||||
// Second call: includes history
|
||||
const secondMsg = mockStreamClaudeAgent.mock.calls[1][0]
|
||||
expect(secondMsg).toContain('<conversation_history>')
|
||||
expect(secondMsg).toContain('What is 2+2?')
|
||||
expect(secondMsg).toContain('The answer is 4')
|
||||
expect(secondMsg).toContain('What was my previous question?')
|
||||
})
|
||||
|
||||
it('accumulates history across multiple exchanges', async () => {
|
||||
let callCount = 0
|
||||
mockStreamClaudeAgent.mockImplementation(async (_msg, _sys, _vault, callbacks) => {
|
||||
callCount++
|
||||
callbacks.onText(`Response ${callCount}`)
|
||||
callbacks.onDone()
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
|
||||
|
||||
await act(async () => { await result.current.sendMessage('Q1') })
|
||||
await act(async () => { await result.current.sendMessage('Q2') })
|
||||
await act(async () => { await result.current.sendMessage('Q3') })
|
||||
|
||||
expect(mockStreamClaudeAgent).toHaveBeenCalledTimes(3)
|
||||
|
||||
// First: no history
|
||||
expect(mockStreamClaudeAgent.mock.calls[0][0]).toBe('Q1')
|
||||
|
||||
// Second: history from first exchange
|
||||
const msg2 = mockStreamClaudeAgent.mock.calls[1][0]
|
||||
expect(msg2).toContain('Q1')
|
||||
expect(msg2).toContain('Response 1')
|
||||
expect(msg2).toContain('Q2')
|
||||
|
||||
// Third: history from both exchanges
|
||||
const msg3 = mockStreamClaudeAgent.mock.calls[2][0]
|
||||
expect(msg3).toContain('Q1')
|
||||
expect(msg3).toContain('Response 1')
|
||||
expect(msg3).toContain('Q2')
|
||||
expect(msg3).toContain('Response 2')
|
||||
expect(msg3).toContain('Q3')
|
||||
})
|
||||
|
||||
it('resets history after clearConversation', async () => {
|
||||
mockStreamClaudeAgent.mockImplementation(async (_msg, _sys, _vault, callbacks) => {
|
||||
callbacks.onText('reply')
|
||||
callbacks.onDone()
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAiAgent(VAULT, undefined))
|
||||
|
||||
await act(async () => { await result.current.sendMessage('hello') })
|
||||
|
||||
act(() => { result.current.clearConversation() })
|
||||
|
||||
await act(async () => { await result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = mockStreamClaudeAgent.mock.calls[mockStreamClaudeAgent.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start')
|
||||
expect(lastCall[0]).not.toContain('<conversation_history>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentMessagesToChatHistory', () => {
|
||||
it('returns empty array for no messages', () => {
|
||||
expect(agentMessagesToChatHistory([])).toEqual([])
|
||||
})
|
||||
|
||||
it('converts agent messages to user/assistant pairs', () => {
|
||||
const msgs: AiAgentMessage[] = [
|
||||
{ userMessage: 'Q1', response: 'A1', actions: [], id: 'm1' },
|
||||
{ userMessage: 'Q2', response: 'A2', actions: [], id: 'm2' },
|
||||
]
|
||||
const result = agentMessagesToChatHistory(msgs)
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0]).toMatchObject({ role: 'user', content: 'Q1' })
|
||||
expect(result[1]).toMatchObject({ role: 'assistant', content: 'A1' })
|
||||
expect(result[2]).toMatchObject({ role: 'user', content: 'Q2' })
|
||||
expect(result[3]).toMatchObject({ role: 'assistant', content: 'A2' })
|
||||
})
|
||||
|
||||
it('skips assistant entry when response is undefined (still streaming)', () => {
|
||||
const msgs: AiAgentMessage[] = [
|
||||
{ userMessage: 'Q1', actions: [], id: 'm1', isStreaming: true },
|
||||
]
|
||||
const result = agentMessagesToChatHistory(msgs)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ role: 'user', content: 'Q1' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,10 @@ import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { AiAction } from '../components/AiMessage'
|
||||
import type { NoteReference } from '../utils/ai-context'
|
||||
import { streamClaudeAgent, buildAgentSystemPrompt } from '../utils/ai-agent'
|
||||
import { nextMessageId } from '../utils/ai-chat'
|
||||
import {
|
||||
nextMessageId, trimHistory, formatMessageWithHistory,
|
||||
type ChatMessage, MAX_HISTORY_TOKENS,
|
||||
} from '../utils/ai-chat'
|
||||
|
||||
export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'error'
|
||||
|
||||
@@ -36,6 +39,18 @@ export interface AgentFileCallbacks {
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
/** Convert completed agent messages to ChatMessage pairs for history embedding. */
|
||||
export function agentMessagesToChatHistory(msgs: AiAgentMessage[]): ChatMessage[] {
|
||||
const history: ChatMessage[] = []
|
||||
for (const msg of msgs) {
|
||||
history.push({ role: 'user', content: msg.userMessage, id: msg.id ?? '' })
|
||||
if (msg.response) {
|
||||
history.push({ role: 'assistant', content: msg.response, id: `${msg.id}-resp` })
|
||||
}
|
||||
}
|
||||
return history
|
||||
}
|
||||
|
||||
export function useAiAgent(
|
||||
vaultPath: string,
|
||||
contextPrompt?: string,
|
||||
@@ -44,21 +59,24 @@ export function useAiAgent(
|
||||
const [messages, setMessages] = useState<AiAgentMessage[]>([])
|
||||
const [status, setStatus] = useState<AgentStatus>('idle')
|
||||
const abortRef = useRef({ aborted: false })
|
||||
const contextRef = useRef(contextPrompt)
|
||||
const responseAccRef = useRef('')
|
||||
const fileCallbacksRef = useRef(fileCallbacks)
|
||||
// Track tool inputs for file-operation detection on ToolDone
|
||||
const toolInputMapRef = useRef<Map<string, { tool: string; input?: string }>>(new Map())
|
||||
// Refs for latest state — avoids stale closures in callbacks.
|
||||
// Synced via useEffect (runs after render, before next user interaction).
|
||||
const messagesRef = useRef<AiAgentMessage[]>([])
|
||||
const statusRef = useRef<AgentStatus>('idle')
|
||||
useEffect(() => { messagesRef.current = messages }, [messages])
|
||||
useEffect(() => { statusRef.current = status }, [status])
|
||||
|
||||
useEffect(() => {
|
||||
contextRef.current = contextPrompt
|
||||
}, [contextPrompt])
|
||||
useEffect(() => {
|
||||
fileCallbacksRef.current = fileCallbacks
|
||||
}, [fileCallbacks])
|
||||
|
||||
const sendMessage = useCallback(async (text: string, references?: NoteReference[]) => {
|
||||
if (!text.trim() || status === 'thinking' || status === 'tool-executing') return
|
||||
const currentStatus = statusRef.current
|
||||
if (!text.trim() || currentStatus === 'thinking' || currentStatus === 'tool-executing') return
|
||||
|
||||
const refs = references && references.length > 0 ? references : undefined
|
||||
|
||||
@@ -89,9 +107,15 @@ export function useAiAgent(
|
||||
update(m => m.reasoningDone ? m : { ...m, reasoningDone: true })
|
||||
}
|
||||
|
||||
const systemPrompt = contextRef.current ?? buildAgentSystemPrompt()
|
||||
const systemPrompt = contextPrompt ?? buildAgentSystemPrompt()
|
||||
|
||||
await streamClaudeAgent(text.trim(), systemPrompt, vaultPath, {
|
||||
// Embed conversation history from previous exchanges.
|
||||
// Uses messagesRef (not closure-captured messages) to avoid stale closures.
|
||||
const chatHistory = agentMessagesToChatHistory(messagesRef.current.filter(m => !m.isStreaming))
|
||||
const trimmedHistory = trimHistory(chatHistory, MAX_HISTORY_TOKENS)
|
||||
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
|
||||
|
||||
await streamClaudeAgent(formattedMessage, systemPrompt, vaultPath, {
|
||||
onThinking: (chunk) => {
|
||||
if (abortRef.current.aborted) return
|
||||
update(m => ({ ...m, reasoning: (m.reasoning ?? '') + chunk }))
|
||||
@@ -178,7 +202,7 @@ export function useAiAgent(
|
||||
fileCallbacksRef.current?.onVaultChanged?.()
|
||||
},
|
||||
})
|
||||
}, [status, vaultPath])
|
||||
}, [vaultPath, contextPrompt])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current.aborted = true
|
||||
|
||||
@@ -15,7 +15,6 @@ interface AppCommandsConfig {
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
tabs: Tab[]
|
||||
entries: VaultEntry[]
|
||||
allContent: Record<string, string>
|
||||
modifiedCount: number
|
||||
selection: SidebarSelection
|
||||
onQuickOpen: () => void
|
||||
@@ -67,6 +66,7 @@ interface AppCommandsConfig {
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onViewChanges: viewChanges,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
@@ -206,6 +207,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
mcpStatus: config.mcpStatus,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onReloadVault: config.onReloadVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
})
|
||||
|
||||
@@ -214,7 +216,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
activeTabPath: config.activeTabPath,
|
||||
entries: config.entries,
|
||||
selection: config.selection,
|
||||
allContent: config.allContent,
|
||||
onSwitchTab: config.onSwitchTab,
|
||||
onReplaceActiveTab: config.onReplaceActiveTab,
|
||||
onSelectNote: config.onSelectNote,
|
||||
|
||||
79
src/hooks/useCodeMirror.test.ts
Normal file
79
src/hooks/useCodeMirror.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
|
||||
|
||||
const noop = () => {}
|
||||
const noopCallbacks: CodeMirrorCallbacks = {
|
||||
onDocChange: noop,
|
||||
onCursorActivity: noop,
|
||||
onSave: noop,
|
||||
onEscape: () => false,
|
||||
}
|
||||
|
||||
describe('useCodeMirror', () => {
|
||||
let container: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container)
|
||||
})
|
||||
|
||||
it('creates an EditorView in the container', () => {
|
||||
const ref = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useCodeMirror(ref, 'hello world', false, noopCallbacks),
|
||||
)
|
||||
expect(result.current.current).not.toBeNull()
|
||||
expect(container.querySelector('.cm-editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls requestMeasure when laputa-zoom-change event fires', () => {
|
||||
const ref = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useCodeMirror(ref, 'hello', false, noopCallbacks),
|
||||
)
|
||||
const view = result.current.current!
|
||||
const spy = vi.spyOn(view, 'requestMeasure')
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('laputa-zoom-change'))
|
||||
})
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('stops listening for zoom changes after unmount', () => {
|
||||
const ref = { current: container }
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useCodeMirror(ref, 'hello', false, noopCallbacks),
|
||||
)
|
||||
const view = result.current.current!
|
||||
const spy = vi.spyOn(view, 'requestMeasure')
|
||||
|
||||
unmount()
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('laputa-zoom-change'))
|
||||
})
|
||||
|
||||
// After unmount, the listener should be removed — requestMeasure should NOT be called.
|
||||
// (The view is also destroyed on unmount, so this verifies cleanup.)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('installs zoomCursorFix that overrides posAtCoords on the view instance', () => {
|
||||
const ref = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
useCodeMirror(ref, 'hello world', false, noopCallbacks),
|
||||
)
|
||||
const view = result.current.current!
|
||||
// The extension overrides posAtCoords on the instance (not the prototype)
|
||||
expect(Object.prototype.hasOwnProperty.call(view, 'posAtCoords')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirro
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight'
|
||||
import { zoomCursorFix } from '../extensions/zoomCursorFix'
|
||||
|
||||
const FONT_FAMILY = '"Berkeley Mono", "JetBrains Mono", "Fira Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
|
||||
@@ -98,6 +99,7 @@ export function useCodeMirror(
|
||||
buildBaseTheme(isDark),
|
||||
frontmatterHighlightTheme(isDark),
|
||||
frontmatterHighlightPlugin,
|
||||
zoomCursorFix(),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
callbacksRef.current.onDocChange(update.state.doc.toString())
|
||||
@@ -111,7 +113,19 @@ export function useCodeMirror(
|
||||
|
||||
const view = new EditorView({ state, parent })
|
||||
viewRef.current = view
|
||||
return () => { view.destroy(); viewRef.current = null }
|
||||
|
||||
// When CSS zoom changes on the document, CodeMirror's cached measurements
|
||||
// (scaleX/scaleY, line heights, character widths) become stale because
|
||||
// ResizeObserver doesn't fire for ancestor zoom changes. Force a re-measure
|
||||
// so cursor placement stays accurate at any zoom level.
|
||||
const handleZoomChange = () => { view.requestMeasure() }
|
||||
window.addEventListener('laputa-zoom-change', handleZoomChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('laputa-zoom-change', handleZoomChange)
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
// Re-create editor when isDark changes (theme is baked into extensions)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDark])
|
||||
|
||||
@@ -255,6 +255,49 @@ describe('install-mcp command', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('reload-vault command', () => {
|
||||
it('is present in Settings group', () => {
|
||||
const config = makeConfig({ onReloadVault: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reload-vault')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('Settings')
|
||||
expect(cmd!.label).toBe('Reload Vault')
|
||||
})
|
||||
|
||||
it('is enabled when onReloadVault is provided', () => {
|
||||
const config = makeConfig({ onReloadVault: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reload-vault')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is disabled when onReloadVault is not provided', () => {
|
||||
const config = makeConfig()
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reload-vault')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('executes onReloadVault callback', () => {
|
||||
const onReloadVault = vi.fn()
|
||||
const config = makeConfig({ onReloadVault })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reload-vault')
|
||||
cmd!.execute()
|
||||
expect(onReloadVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has searchable keywords', () => {
|
||||
const config = makeConfig({ onReloadVault: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reload-vault')
|
||||
expect(cmd!.keywords).toContain('reload')
|
||||
expect(cmd!.keywords).toContain('refresh')
|
||||
expect(cmd!.keywords).toContain('rescan')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTypeCommands', () => {
|
||||
it('creates new and list commands for each type', () => {
|
||||
const onCreateNoteOfType = vi.fn()
|
||||
|
||||
@@ -21,6 +21,7 @@ interface CommandRegistryConfig {
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
|
||||
onQuickOpen: () => void
|
||||
@@ -199,6 +200,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onReindexVault,
|
||||
onReloadVault,
|
||||
onRepairVault,
|
||||
} = config
|
||||
|
||||
@@ -262,6 +264,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
|
||||
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
|
||||
{ id: 'reindex-vault', label: 'Reindex Vault', group: 'Settings', keywords: ['reindex', 'index', 'search', 'rebuild', 'refresh'], enabled: !!onReindexVault, execute: () => onReindexVault?.() },
|
||||
{ id: 'reload-vault', label: 'Reload Vault', group: 'Settings', keywords: ['reload', 'refresh', 'rescan', 'sync', 'filesystem', 'cache'], enabled: !!onReloadVault, execute: () => onReloadVault?.() },
|
||||
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
|
||||
|
||||
// Type-aware: "New [Type]" and "List [Type]"
|
||||
@@ -281,6 +284,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onReindexVault, onRepairVault,
|
||||
onReindexVault, onReloadVault, onRepairVault,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -171,6 +171,22 @@ describe('useEditorSave', () => {
|
||||
// (We'll check via the next handleSave call)
|
||||
})
|
||||
|
||||
it('handleContentChange syncs content to tab state immediately', () => {
|
||||
const { result } = renderSaveHook()
|
||||
|
||||
act(() => {
|
||||
result.current.handleContentChange('/test/note.md', '---\ntitle: T\n---\n\n# T\n\nLive edits')
|
||||
})
|
||||
|
||||
// setTabs must be called on every content change (not just on save)
|
||||
// so that consumers like the AI panel see current editor content
|
||||
expect(setTabs).toHaveBeenCalled()
|
||||
const updater = setTabs.mock.calls[0][0]
|
||||
const tabs = [{ entry: { path: '/test/note.md' }, content: 'stale' }]
|
||||
const updated = updater(tabs)
|
||||
expect(updated[0].content).toBe('---\ntitle: T\n---\n\n# T\n\nLive edits')
|
||||
})
|
||||
|
||||
it('save updates tab content with edited body, not original (regression)', async () => {
|
||||
const { result } = renderSaveHook()
|
||||
|
||||
|
||||
@@ -66,10 +66,14 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
}
|
||||
}, [flushPending, setToastMessage, onAfterSave, saveNote, onNotePersisted])
|
||||
|
||||
/** Called by Editor onChange — buffers the latest content without saving */
|
||||
/** Called by Editor onChange — buffers the latest content and syncs tab state
|
||||
* so consumers (e.g. AI panel) always see current editor content. */
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
pendingContentRef.current = { path, content }
|
||||
}, [])
|
||||
setTabs((prev: Tab[]) =>
|
||||
prev.map((t) => t.entry.path === path ? { ...t, content } : t)
|
||||
)
|
||||
}, [setTabs])
|
||||
|
||||
/** Save pending content for a specific path (used before rename) */
|
||||
const savePendingForPath = useCallback(
|
||||
|
||||
@@ -4,18 +4,16 @@ import { extractOutgoingLinks } from '../utils/wikilinks'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export function useEditorSaveWithLinks(config: {
|
||||
updateContent: (path: string, content: string) => void
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave: () => void
|
||||
onNotePersisted?: (path: string) => void
|
||||
}) {
|
||||
const { updateContent, updateEntry } = config
|
||||
const { updateEntry } = config
|
||||
const saveContent = useCallback((path: string, content: string) => {
|
||||
updateContent(path, content)
|
||||
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
|
||||
}, [updateContent, updateEntry])
|
||||
}, [updateEntry])
|
||||
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
const prevLinksKeyRef = useRef('')
|
||||
|
||||
@@ -342,4 +342,120 @@ describe('useEntryActions', () => {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('failed disk writes do not update React state', () => {
|
||||
it('handleCustomizeType does not update entry when frontmatter write fails', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' })
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await expect(
|
||||
act(() => result.current.handleCustomizeType('Recipe', 'star', 'red'))
|
||||
).rejects.toThrow('disk full')
|
||||
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleRenameSection does not update entry when frontmatter write fails', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' })
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await expect(
|
||||
act(() => result.current.handleRenameSection('Recipe', 'Dishes'))
|
||||
).rejects.toThrow('disk full')
|
||||
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleRenameSection does not update entry when delete property fails', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: 'Dishes' })
|
||||
handleDeleteProperty.mockRejectedValueOnce(new Error('disk full'))
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await expect(
|
||||
act(() => result.current.handleRenameSection('Recipe', ''))
|
||||
).rejects.toThrow('disk full')
|
||||
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleToggleTypeVisibility does not update entry when frontmatter write fails (hide)', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: null })
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await expect(
|
||||
act(() => result.current.handleToggleTypeVisibility('Journal'))
|
||||
).rejects.toThrow('disk full')
|
||||
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleToggleTypeVisibility does not update entry when delete property fails (show)', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/type/journal.md', visible: false })
|
||||
handleDeleteProperty.mockRejectedValueOnce(new Error('disk full'))
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await expect(
|
||||
act(() => result.current.handleToggleTypeVisibility('Journal'))
|
||||
).rejects.toThrow('disk full')
|
||||
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onBeforeAction callback', () => {
|
||||
function setupWithBeforeAction(onBeforeAction: ReturnType<typeof vi.fn>) {
|
||||
return renderHook(() =>
|
||||
useEntryActions({
|
||||
entries: [], updateEntry, handleUpdateFrontmatter, handleDeleteProperty,
|
||||
setToastMessage, createTypeEntry, onFrontmatterPersisted, onBeforeAction,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
it('calls onBeforeAction before trashing a note', async () => {
|
||||
const callOrder: string[] = []
|
||||
const onBeforeAction = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('beforeAction')
|
||||
return Promise.resolve()
|
||||
})
|
||||
handleUpdateFrontmatter.mockImplementation(() => {
|
||||
callOrder.push('updateFrontmatter')
|
||||
return Promise.resolve()
|
||||
})
|
||||
const { result } = setupWithBeforeAction(onBeforeAction)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleTrashNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(onBeforeAction).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
expect(callOrder[0]).toBe('beforeAction')
|
||||
expect(callOrder[1]).toBe('updateFrontmatter')
|
||||
})
|
||||
|
||||
it('calls onBeforeAction before archiving a note', async () => {
|
||||
const onBeforeAction = vi.fn().mockResolvedValue(undefined)
|
||||
const { result } = setupWithBeforeAction(onBeforeAction)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleArchiveNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(onBeforeAction).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['trash', 'handleTrashNote'] as const,
|
||||
['archive', 'handleArchiveNote'] as const,
|
||||
])('does not proceed with %s when onBeforeAction rejects', async (_label, method) => {
|
||||
const { result } = setupWithBeforeAction(vi.fn().mockRejectedValue(new Error('Save failed')))
|
||||
|
||||
await expect(act(() => result.current[method]('/vault/note/test.md'))).rejects.toThrow('Save failed')
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,23 +9,32 @@ interface EntryActionsConfig {
|
||||
setToastMessage: (msg: string | null) => void
|
||||
createTypeEntry: (typeName: string) => Promise<VaultEntry>
|
||||
onFrontmatterPersisted?: () => void
|
||||
/** Called before trash/archive to flush unsaved editor content to disk. */
|
||||
onBeforeAction?: (path: string) => Promise<void>
|
||||
}
|
||||
|
||||
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
return entries.find((e) => e.isA === 'Type' && e.title === typeName)
|
||||
}
|
||||
|
||||
async function findOrCreateType(
|
||||
entries: VaultEntry[], typeName: string, create: (name: string) => Promise<VaultEntry>,
|
||||
): Promise<VaultEntry> {
|
||||
return findTypeEntry(entries, typeName) ?? await create(typeName)
|
||||
}
|
||||
|
||||
export function useEntryActions({
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, onFrontmatterPersisted,
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, onFrontmatterPersisted, onBeforeAction,
|
||||
}: EntryActionsConfig) {
|
||||
const handleTrashNote = useCallback(async (path: string) => {
|
||||
await onBeforeAction?.(path)
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
await handleUpdateFrontmatter(path, 'Trashed', true)
|
||||
await handleUpdateFrontmatter(path, 'Trashed at', now)
|
||||
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
|
||||
setToastMessage('Note moved to trash')
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
}, [onBeforeAction, handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleRestoreNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'Trashed', false)
|
||||
@@ -36,11 +45,12 @@ export function useEntryActions({
|
||||
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleArchiveNote = useCallback(async (path: string) => {
|
||||
await onBeforeAction?.(path)
|
||||
await handleUpdateFrontmatter(path, 'archived', true)
|
||||
updateEntry(path, { archived: true })
|
||||
setToastMessage('Note archived')
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
}, [onBeforeAction, handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleUnarchiveNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'archived', false)
|
||||
@@ -50,18 +60,16 @@ export function useEntryActions({
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
updateEntry(typeEntry.path, { icon, color })
|
||||
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
|
||||
updateEntry(typeEntry.path, { icon, color })
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleReorderSections = useCallback(async (orderedTypes: { typeName: string; order: number }[]) => {
|
||||
for (const { typeName, order } of orderedTypes) {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'order', order)
|
||||
updateEntry(typeEntry.path, { order })
|
||||
}
|
||||
@@ -69,35 +77,32 @@ export function useEntryActions({
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleUpdateTypeTemplate = useCallback(async (typeName: string, template: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'template', template)
|
||||
updateEntry(typeEntry.path, { template: template || null })
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
|
||||
const trimmed = label.trim()
|
||||
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
|
||||
if (trimmed) {
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'sidebar label', trimmed)
|
||||
} else {
|
||||
await handleDeleteProperty(typeEntry.path, 'sidebar label')
|
||||
}
|
||||
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
|
||||
if (typeEntry.visible === false) {
|
||||
updateEntry(typeEntry.path, { visible: null })
|
||||
await handleDeleteProperty(typeEntry.path, 'visible')
|
||||
updateEntry(typeEntry.path, { visible: null })
|
||||
} else {
|
||||
updateEntry(typeEntry.path, { visible: false })
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'visible', false)
|
||||
updateEntry(typeEntry.path, { visible: false })
|
||||
}
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
@@ -59,8 +59,6 @@ describe('useKeyboardNavigation', () => {
|
||||
]
|
||||
|
||||
const selection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
const allContent: Record<string, string> = {}
|
||||
|
||||
let addedListeners: { type: string; handler: EventListenerOrEventListenerObject }[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -85,7 +83,7 @@ describe('useKeyboardNavigation', () => {
|
||||
it('registers keydown listener on mount', () => {
|
||||
renderHook(() =>
|
||||
useKeyboardNavigation({
|
||||
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
|
||||
tabs, activeTabPath: '/vault/a.md', entries, selection,
|
||||
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
||||
})
|
||||
)
|
||||
@@ -96,7 +94,7 @@ describe('useKeyboardNavigation', () => {
|
||||
it('switches to next tab on Cmd+Shift+ArrowRight (browser mode)', () => {
|
||||
renderHook(() =>
|
||||
useKeyboardNavigation({
|
||||
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
|
||||
tabs, activeTabPath: '/vault/a.md', entries, selection,
|
||||
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
||||
})
|
||||
)
|
||||
@@ -113,7 +111,7 @@ describe('useKeyboardNavigation', () => {
|
||||
it('switches to previous tab on Cmd+Shift+ArrowLeft (browser mode)', () => {
|
||||
renderHook(() =>
|
||||
useKeyboardNavigation({
|
||||
tabs, activeTabPath: '/vault/b.md', entries, selection, allContent,
|
||||
tabs, activeTabPath: '/vault/b.md', entries, selection,
|
||||
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
||||
})
|
||||
)
|
||||
@@ -130,7 +128,7 @@ describe('useKeyboardNavigation', () => {
|
||||
it('wraps around when navigating past last tab', () => {
|
||||
renderHook(() =>
|
||||
useKeyboardNavigation({
|
||||
tabs, activeTabPath: '/vault/c.md', entries, selection, allContent,
|
||||
tabs, activeTabPath: '/vault/c.md', entries, selection,
|
||||
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
||||
})
|
||||
)
|
||||
@@ -147,7 +145,7 @@ describe('useKeyboardNavigation', () => {
|
||||
it('navigates to next note on Cmd+Alt+ArrowDown', () => {
|
||||
renderHook(() =>
|
||||
useKeyboardNavigation({
|
||||
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
|
||||
tabs, activeTabPath: '/vault/a.md', entries, selection,
|
||||
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
||||
})
|
||||
)
|
||||
@@ -164,7 +162,7 @@ describe('useKeyboardNavigation', () => {
|
||||
it('navigates to previous note on Cmd+Alt+ArrowUp', () => {
|
||||
renderHook(() =>
|
||||
useKeyboardNavigation({
|
||||
tabs, activeTabPath: '/vault/b.md', entries, selection, allContent,
|
||||
tabs, activeTabPath: '/vault/b.md', entries, selection,
|
||||
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
||||
})
|
||||
)
|
||||
@@ -181,7 +179,7 @@ describe('useKeyboardNavigation', () => {
|
||||
it('selects first note when no active tab', () => {
|
||||
renderHook(() =>
|
||||
useKeyboardNavigation({
|
||||
tabs: [], activeTabPath: null, entries, selection, allContent,
|
||||
tabs: [], activeTabPath: null, entries, selection,
|
||||
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
||||
})
|
||||
)
|
||||
@@ -198,7 +196,7 @@ describe('useKeyboardNavigation', () => {
|
||||
it('does nothing without modifier keys', () => {
|
||||
renderHook(() =>
|
||||
useKeyboardNavigation({
|
||||
tabs, activeTabPath: '/vault/a.md', entries, selection, allContent,
|
||||
tabs, activeTabPath: '/vault/a.md', entries, selection,
|
||||
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
||||
})
|
||||
)
|
||||
@@ -217,7 +215,7 @@ describe('useKeyboardNavigation', () => {
|
||||
it('does nothing with empty tabs for tab navigation', () => {
|
||||
renderHook(() =>
|
||||
useKeyboardNavigation({
|
||||
tabs: [], activeTabPath: null, entries, selection, allContent,
|
||||
tabs: [], activeTabPath: null, entries, selection,
|
||||
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ interface KeyboardNavigationOptions {
|
||||
activeTabPath: string | null
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
allContent: Record<string, string>
|
||||
onSwitchTab: (path: string) => void
|
||||
onReplaceActiveTab: (entry: VaultEntry) => void
|
||||
onSelectNote: (entry: VaultEntry) => void
|
||||
@@ -22,10 +21,9 @@ interface KeyboardNavigationOptions {
|
||||
function computeVisibleNotes(
|
||||
entries: VaultEntry[],
|
||||
selection: SidebarSelection,
|
||||
allContent: Record<string, string>,
|
||||
): VaultEntry[] {
|
||||
if (selection.kind === 'entity') {
|
||||
return buildRelationshipGroups(selection.entry, entries, allContent)
|
||||
return buildRelationshipGroups(selection.entry, entries)
|
||||
.flatMap((g) => g.entries)
|
||||
}
|
||||
return [...filterEntries(entries, selection)].sort(sortByModified)
|
||||
@@ -93,12 +91,12 @@ function useLatestRef<T>(value: T): React.RefObject<T> {
|
||||
}
|
||||
|
||||
export function useKeyboardNavigation({
|
||||
tabs, activeTabPath, entries, selection, allContent,
|
||||
tabs, activeTabPath, entries, selection,
|
||||
onSwitchTab, onReplaceActiveTab, onSelectNote,
|
||||
}: KeyboardNavigationOptions) {
|
||||
const visibleNotes = useMemo(
|
||||
() => computeVisibleNotes(entries, selection, allContent),
|
||||
[entries, selection, allContent],
|
||||
() => computeVisibleNotes(entries, selection),
|
||||
[entries, selection],
|
||||
)
|
||||
|
||||
const tabsRef = useLatestRef(tabs)
|
||||
|
||||
@@ -35,6 +35,7 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onViewChanges: vi.fn(),
|
||||
onInstallMcp: vi.fn(),
|
||||
onReindexVault: vi.fn(),
|
||||
onReloadVault: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
|
||||
activeTabPath: '/vault/test.md',
|
||||
@@ -306,6 +307,12 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onReindexVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vault-reload triggers reload vault', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('vault-reload', h)
|
||||
expect(h.onReloadVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
it('unknown event ID does nothing', () => {
|
||||
const h = makeHandlers()
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface MenuEventHandlers {
|
||||
onViewChanges?: () => void
|
||||
onInstallMcp?: () => void
|
||||
onReindexVault?: () => void
|
||||
onReloadVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
@@ -79,7 +80,7 @@ type OptionalHandler =
|
||||
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
|
||||
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
|
||||
| 'onCreateTheme' | 'onRestoreDefaultThemes'
|
||||
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onRepairVault'
|
||||
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault'
|
||||
|
||||
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'view-go-back': 'onGoBack',
|
||||
@@ -99,6 +100,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-view-changes': 'onViewChanges',
|
||||
'vault-install-mcp': 'onInstallMcp',
|
||||
'vault-reindex': 'onReindexVault',
|
||||
'vault-reload': 'onReloadVault',
|
||||
'vault-repair': 'onRepairVault',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
slugify,
|
||||
@@ -241,8 +241,8 @@ describe('DEFAULT_TEMPLATES', () => {
|
||||
|
||||
describe('resolveNewNote', () => {
|
||||
it('uses TYPE_FOLDER_MAP for known types', () => {
|
||||
const { entry, content } = resolveNewNote('My Project', 'Project')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/project/my-project.md')
|
||||
const { entry, content } = resolveNewNote('My Project', 'Project', '/my/vault')
|
||||
expect(entry.path).toBe('/my/vault/project/my-project.md')
|
||||
expect(entry.isA).toBe('Project')
|
||||
expect(entry.status).toBe('Active')
|
||||
expect(content).toContain('type: Project')
|
||||
@@ -250,31 +250,43 @@ describe('resolveNewNote', () => {
|
||||
})
|
||||
|
||||
it('falls back to slugified type for custom types', () => {
|
||||
const { entry } = resolveNewNote('First Recipe', 'Recipe')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/recipe/first-recipe.md')
|
||||
const { entry } = resolveNewNote('First Recipe', 'Recipe', '/my/vault')
|
||||
expect(entry.path).toBe('/my/vault/recipe/first-recipe.md')
|
||||
})
|
||||
|
||||
it('omits status for Topic type', () => {
|
||||
const { entry, content } = resolveNewNote('Machine Learning', 'Topic')
|
||||
const { entry, content } = resolveNewNote('Machine Learning', 'Topic', '/my/vault')
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).not.toContain('status:')
|
||||
})
|
||||
|
||||
it('omits status for Person type', () => {
|
||||
const { entry } = resolveNewNote('John Doe', 'Person')
|
||||
const { entry } = resolveNewNote('John Doe', 'Person', '/my/vault')
|
||||
expect(entry.status).toBeNull()
|
||||
})
|
||||
|
||||
it('uses provided vault path instead of hardcoded path', () => {
|
||||
const { entry } = resolveNewNote('Test', 'Note', '/other/vault')
|
||||
expect(entry.path).toBe('/other/vault/note/test.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewType', () => {
|
||||
it('creates a type entry in the type folder', () => {
|
||||
const { entry, content } = resolveNewType('Recipe')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/type/recipe.md')
|
||||
const { entry, content } = resolveNewType('Recipe', '/my/vault')
|
||||
expect(entry.path).toBe('/my/vault/type/recipe.md')
|
||||
expect(entry.isA).toBe('Type')
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).toContain('type: Type')
|
||||
expect(content).toContain('# Recipe')
|
||||
})
|
||||
|
||||
it('uses provided vault path instead of hardcoded path', () => {
|
||||
const { entry } = resolveNewType('Responsibility', '/other/vault')
|
||||
expect(entry.path).toBe('/other/vault/type/responsibility.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('frontmatterToEntryPatch', () => {
|
||||
@@ -365,8 +377,8 @@ describe('buildDailyNoteContent', () => {
|
||||
|
||||
describe('resolveDailyNote', () => {
|
||||
it('creates entry in journal folder with date as filename', () => {
|
||||
const { entry } = resolveDailyNote('2026-03-02')
|
||||
expect(entry.path).toBe('/Users/luca/Laputa/journal/2026-03-02.md')
|
||||
const { entry } = resolveDailyNote('2026-03-02', '/my/vault')
|
||||
expect(entry.path).toBe('/my/vault/journal/2026-03-02.md')
|
||||
expect(entry.filename).toBe('2026-03-02.md')
|
||||
expect(entry.title).toBe('2026-03-02')
|
||||
expect(entry.isA).toBe('Journal')
|
||||
@@ -374,10 +386,16 @@ describe('resolveDailyNote', () => {
|
||||
})
|
||||
|
||||
it('returns content with daily note template', () => {
|
||||
const { content } = resolveDailyNote('2026-03-02')
|
||||
const { content } = resolveDailyNote('2026-03-02', '/my/vault')
|
||||
expect(content).toContain('type: Journal')
|
||||
expect(content).toContain('## Intentions')
|
||||
})
|
||||
|
||||
it('uses provided vault path instead of hardcoded path', () => {
|
||||
const { entry } = resolveDailyNote('2026-03-02', '/other/vault')
|
||||
expect(entry.path).toBe('/other/vault/journal/2026-03-02.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findDailyNote', () => {
|
||||
@@ -408,12 +426,11 @@ describe('findDailyNote', () => {
|
||||
describe('useNoteActions hook', () => {
|
||||
const addEntry = vi.fn()
|
||||
const removeEntry = vi.fn()
|
||||
const updateContent = vi.fn()
|
||||
const updateEntry = vi.fn()
|
||||
const setToastMessage = vi.fn()
|
||||
|
||||
const makeConfig = (entries: VaultEntry[] = []): NoteActionsConfig => ({
|
||||
addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry,
|
||||
addEntry, removeEntry, entries, setToastMessage, updateEntry, vaultPath: '/test/vault',
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -429,11 +446,10 @@ describe('useNoteActions hook', () => {
|
||||
})
|
||||
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
const [createdEntry, createdContent] = addEntry.mock.calls[0]
|
||||
const [createdEntry] = addEntry.mock.calls[0]
|
||||
expect(createdEntry.title).toBe('Test Note')
|
||||
expect(createdEntry.isA).toBe('Note')
|
||||
expect(createdEntry.path).toContain('note/test-note.md')
|
||||
expect(createdContent).toContain('title: Test Note')
|
||||
})
|
||||
|
||||
it('handleCreateNote opens tab immediately (before addEntry resolves)', () => {
|
||||
@@ -499,7 +515,6 @@ describe('useNoteActions hook', () => {
|
||||
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
|
||||
})
|
||||
|
||||
expect(updateContent).toHaveBeenCalled()
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: 'Done' })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
|
||||
})
|
||||
@@ -526,7 +541,6 @@ describe('useNoteActions hook', () => {
|
||||
await result.current.handleDeleteProperty('/vault/note.md', 'status')
|
||||
})
|
||||
|
||||
expect(updateContent).toHaveBeenCalled()
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note.md', { status: null })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Property deleted')
|
||||
})
|
||||
@@ -580,9 +594,9 @@ describe('useNoteActions hook', () => {
|
||||
result.current.handleCreateNote('My Project', 'Project')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Objective')
|
||||
expect(createdContent).toContain('## Key Results')
|
||||
const tabContent = result.current.tabs[0].content
|
||||
expect(tabContent).toContain('## Objective')
|
||||
expect(tabContent).toContain('## Key Results')
|
||||
})
|
||||
|
||||
it('handleCreateNote uses custom template from type entry', () => {
|
||||
@@ -593,9 +607,9 @@ describe('useNoteActions hook', () => {
|
||||
result.current.handleCreateNote('Pasta', 'Recipe')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Ingredients')
|
||||
expect(createdContent).toContain('## Steps')
|
||||
const tabContent = result.current.tabs[0].content
|
||||
expect(tabContent).toContain('## Ingredients')
|
||||
expect(tabContent).toContain('## Steps')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate uses template for typed notes', () => {
|
||||
@@ -606,8 +620,8 @@ describe('useNoteActions hook', () => {
|
||||
result.current.handleCreateNoteImmediate('Project')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Custom Template')
|
||||
const tabContent = result.current.tabs[0].content
|
||||
expect(tabContent).toContain('## Custom Template')
|
||||
})
|
||||
|
||||
it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => {
|
||||
@@ -835,4 +849,106 @@ describe('useNoteActions hook', () => {
|
||||
expect(clearUnsaved).toHaveBeenCalledWith(createdPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('move note to type folder on type change', () => {
|
||||
it('calls move_note_to_type_folder when type key is updated', async () => {
|
||||
const entry = makeEntry({ path: '/test/vault/note/my-note.md', filename: 'my-note.md', title: 'My Note', isA: 'Note' })
|
||||
const replaceEntry = vi.fn()
|
||||
const config = makeConfig([entry])
|
||||
config.replaceEntry = replaceEntry
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/quarter/my-note.md', updated_links: 0, moved: true }
|
||||
if (cmd === 'get_note_content') return '---\ntype: Quarter\n---\n# My Note\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/test/vault/note/my-note.md', 'type', 'Quarter')
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('move_note_to_type_folder', expect.objectContaining({
|
||||
vault_path: '/test/vault',
|
||||
note_path: '/test/vault/note/my-note.md',
|
||||
new_type: 'Quarter',
|
||||
}))
|
||||
expect(replaceEntry).toHaveBeenCalledWith(
|
||||
'/test/vault/note/my-note.md',
|
||||
expect.objectContaining({ path: '/test/vault/quarter/my-note.md' }),
|
||||
)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note moved to quarter/')
|
||||
})
|
||||
|
||||
it('does not call move when type key is not being changed', async () => {
|
||||
const config = makeConfig()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('')
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/vault/note.md', 'status', 'Done')
|
||||
})
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything())
|
||||
})
|
||||
|
||||
it('does not move when result.moved is false (already in correct folder)', async () => {
|
||||
const entry = makeEntry({ path: '/test/vault/quarter/my-note.md', filename: 'my-note.md', isA: 'Quarter' })
|
||||
const replaceEntry = vi.fn()
|
||||
const config = makeConfig([entry])
|
||||
config.replaceEntry = replaceEntry
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/quarter/my-note.md', updated_links: 0, moved: false }
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/test/vault/quarter/my-note.md', 'type', 'Quarter')
|
||||
})
|
||||
|
||||
expect(replaceEntry).not.toHaveBeenCalled()
|
||||
// Should still show 'Property updated' toast (not move toast)
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Property updated')
|
||||
})
|
||||
|
||||
it('handles Is A key (case-insensitive)', async () => {
|
||||
const entry = makeEntry({ path: '/test/vault/note/my-note.md' })
|
||||
const config = makeConfig([entry])
|
||||
config.replaceEntry = vi.fn()
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/project/my-note.md', updated_links: 0, moved: true }
|
||||
if (cmd === 'get_note_content') return '---\nIs A: Project\n---\n# My Note\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/test/vault/note/my-note.md', 'Is A', 'Project')
|
||||
})
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('move_note_to_type_folder', expect.objectContaining({
|
||||
new_type: 'Project',
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not move when value is empty or null-like', async () => {
|
||||
const config = makeConfig()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('')
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/vault/note.md', 'type', '')
|
||||
})
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalledWith('move_note_to_type_folder', expect.anything())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,13 +19,19 @@ interface RenameResult {
|
||||
updated_files: number
|
||||
}
|
||||
|
||||
interface MoveResult {
|
||||
new_path: string
|
||||
updated_links: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
export interface NoteActionsConfig {
|
||||
addEntry: (entry: VaultEntry, content: string) => void
|
||||
addEntry: (entry: VaultEntry) => void
|
||||
removeEntry: (path: string) => void
|
||||
updateContent: (path: string, content: string) => void
|
||||
entries: VaultEntry[]
|
||||
setToastMessage: (msg: string | null) => void
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
vaultPath: string
|
||||
addPendingSave?: (path: string) => void
|
||||
removePendingSave?: (path: string) => void
|
||||
trackUnsaved?: (path: string) => void
|
||||
@@ -35,6 +41,23 @@ export interface NoteActionsConfig {
|
||||
markContentPending?: (path: string, content: string) => void
|
||||
/** Called after a new note is persisted to disk (e.g. to refresh git status for Changes view). */
|
||||
onNewNotePersisted?: () => void
|
||||
/** Replace an entry at oldPath with a patch (handles path changes in entries). */
|
||||
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
|
||||
}
|
||||
|
||||
async function performMoveToTypeFolder(
|
||||
vaultPath: string, notePath: string, newType: string,
|
||||
): Promise<MoveResult> {
|
||||
if (isTauri()) {
|
||||
return invoke<MoveResult>('move_note_to_type_folder', { vaultPath, notePath, newType })
|
||||
}
|
||||
return mockInvoke<MoveResult>('move_note_to_type_folder', { vault_path: vaultPath, note_path: notePath, new_type: newType })
|
||||
}
|
||||
|
||||
/** Check if a frontmatter key represents the note type. */
|
||||
function isTypeKey(key: string): boolean {
|
||||
const k = key.toLowerCase().replace(/\s+/g, '_')
|
||||
return k === 'type' || k === 'is_a'
|
||||
}
|
||||
|
||||
async function performRename(
|
||||
@@ -175,9 +198,9 @@ export function frontmatterToEntryPatch(
|
||||
return updates[k] ?? {}
|
||||
}
|
||||
|
||||
function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry, c: string) => void) {
|
||||
function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: VaultEntry) => void) {
|
||||
if (!isTauri()) addMockEntry(entry, content)
|
||||
addEntry(entry, content)
|
||||
addEntry(entry)
|
||||
}
|
||||
|
||||
export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string {
|
||||
@@ -188,17 +211,17 @@ export function buildNoteContent(title: string, type: string, status: string | n
|
||||
return `${lines.join('\n')}\n\n# ${title}\n${body}`
|
||||
}
|
||||
|
||||
export function resolveNewNote(title: string, type: string, template?: string | null): { entry: VaultEntry; content: string } {
|
||||
export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { entry: VaultEntry; content: string } {
|
||||
const folder = TYPE_FOLDER_MAP[type] || slugify(type)
|
||||
const slug = slugify(title)
|
||||
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/${folder}/${slug}.md`, slug, title, type, status })
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/${folder}/${slug}.md`, slug, title, type, status })
|
||||
return { entry, content: buildNoteContent(title, type, status, template) }
|
||||
}
|
||||
|
||||
export function resolveNewType(typeName: string): { entry: VaultEntry; content: string } {
|
||||
export function resolveNewType(typeName: string, vaultPath: string): { entry: VaultEntry; content: string } {
|
||||
const slug = slugify(typeName)
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/type/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
|
||||
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n\n` }
|
||||
}
|
||||
|
||||
@@ -211,8 +234,8 @@ export function buildDailyNoteContent(date: string): string {
|
||||
return `${lines.join('\n')}\n\n# ${date}\n\n## Intentions\n\n\n\n## Reflections\n\n`
|
||||
}
|
||||
|
||||
export function resolveDailyNote(date: string): { entry: VaultEntry; content: string } {
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
|
||||
export function resolveDailyNote(date: string, vaultPath: string): { entry: VaultEntry; content: string } {
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/journal/${date}.md`, slug: date, title: date, type: 'Journal', status: null })
|
||||
return { entry, content: buildDailyNoteContent(date) }
|
||||
}
|
||||
|
||||
@@ -224,11 +247,11 @@ export function findDailyNote(entries: VaultEntry[], date: string): VaultEntry |
|
||||
type PersistFn = (resolved: { entry: VaultEntry; content: string }) => void
|
||||
|
||||
/** Open today's daily note: navigate to it if it exists, or create + persist a new one. */
|
||||
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn): void {
|
||||
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn, vaultPath: string): void {
|
||||
const date = todayDateString()
|
||||
const existing = findDailyNote(entries, date)
|
||||
if (existing) selectNote(existing)
|
||||
else persist(resolveDailyNote(date))
|
||||
else persist(resolveDailyNote(date, vaultPath))
|
||||
signalFocusEditor()
|
||||
}
|
||||
|
||||
@@ -274,7 +297,7 @@ function persistOptimistic(path: string, content: string, cbs: PersistCallbacks)
|
||||
* deferred and doesn't block the tab from rendering. */
|
||||
function createAndPersist(
|
||||
resolved: { entry: VaultEntry; content: string },
|
||||
addFn: (e: VaultEntry, c: string) => void,
|
||||
addFn: (e: VaultEntry) => void,
|
||||
openTab: (e: VaultEntry, c: string) => void,
|
||||
cbs: PersistCallbacks,
|
||||
): void {
|
||||
@@ -324,7 +347,7 @@ async function runFrontmatterAndApply(
|
||||
}
|
||||
|
||||
export function useNoteActions(config: NoteActionsConfig) {
|
||||
const { addEntry, removeEntry, updateContent, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config
|
||||
const { addEntry, removeEntry, entries, setToastMessage, updateEntry, addPendingSave, removePendingSave } = config
|
||||
const tabMgmt = useTabManagement()
|
||||
const { setTabs, handleSelectNote, openTabWithContent, handleCloseTab, handleCloseTabRef, activeTabPathRef, handleSwitchTab } = tabMgmt
|
||||
const tabsRef = useRef(tabMgmt.tabs)
|
||||
@@ -336,8 +359,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
|
||||
const updateTabContent = useCallback((path: string, newContent: string) => {
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
|
||||
updateContent(path, newContent)
|
||||
}, [setTabs, updateContent])
|
||||
}, [setTabs])
|
||||
|
||||
const handleNavigateWikilink = useCallback(
|
||||
(target: string) => navigateWikilink(entries, target, handleSelectNote),
|
||||
@@ -366,22 +388,22 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
const template = resolveTemplate(entries, type)
|
||||
persistNew(resolveNewNote(title, type, template))
|
||||
}, [entries, persistNew])
|
||||
persistNew(resolveNewNote(title, type, config.vaultPath, template))
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, template)
|
||||
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
}, [entries, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
/** Close tab and discard entry+unsaved state if the note was never persisted. */
|
||||
const handleCloseTabWithCleanup = useCallback((path: string) => {
|
||||
@@ -392,17 +414,17 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
// Keep handleCloseTabRef in sync so Cmd+W and menu events also clean up unsaved notes.
|
||||
useEffect(() => { handleCloseTabRef.current = handleCloseTabWithCleanup })
|
||||
|
||||
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew), [entries, handleSelectNote, persistNew])
|
||||
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName)), [persistNew])
|
||||
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName, config.vaultPath)), [persistNew, config.vaultPath])
|
||||
|
||||
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
|
||||
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
|
||||
const resolved = resolveNewType(typeName)
|
||||
const resolved = resolveNewType(typeName, config.vaultPath)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
await persistNewNote(resolved.entry.path, resolved.content)
|
||||
return resolved.entry
|
||||
}, [addEntry])
|
||||
}, [addEntry, config.vaultPath])
|
||||
|
||||
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }
|
||||
|
||||
@@ -442,7 +464,30 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleOpenDailyNote,
|
||||
handleCreateType,
|
||||
createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
|
||||
await runFrontmatterOp('update', path, key, value)
|
||||
if (isTypeKey(key) && typeof value === 'string' && value !== '') {
|
||||
try {
|
||||
const result = await performMoveToTypeFolder(config.vaultPath, path, value)
|
||||
if (result.moved) {
|
||||
const entry = entries.find(e => e.path === path)
|
||||
if (entry) {
|
||||
const newFilename = result.new_path.split('/').pop() ?? entry.filename
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
config.replaceEntry?.(path, { ...entry, path: result.new_path, filename: newFilename })
|
||||
setTabs(prev => prev.map(t => t.entry.path === path
|
||||
? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: newContent }
|
||||
: t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
}
|
||||
const folder = result.new_path.split('/').slice(-2, -1)[0] ?? ''
|
||||
setToastMessage(`Note moved to ${folder}/`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to move note to type folder:', err)
|
||||
}
|
||||
}
|
||||
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage]),
|
||||
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
|
||||
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleRenameNote,
|
||||
|
||||
@@ -175,6 +175,80 @@ describe('useNoteSearch', () => {
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ranks exact title match first even with many prefix competitors', () => {
|
||||
const ranked: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', modifiedAt: 1700000010 }),
|
||||
makeEntry({ path: '/vault/rk.md', title: 'Refactoring Key Ideas', modifiedAt: 1700000009 }),
|
||||
makeEntry({ path: '/vault/rp.md', title: 'Refactoring Patterns', modifiedAt: 1700000008 }),
|
||||
makeEntry({ path: '/vault/rs.md', title: 'Refactoring Strategy', modifiedAt: 1700000007 }),
|
||||
makeEntry({ path: '/vault/rt.md', title: 'Refactoring Techniques', modifiedAt: 1700000006 }),
|
||||
makeEntry({ path: '/vault/rb.md', title: 'Refactoring Best Practices', modifiedAt: 1700000005 }),
|
||||
makeEntry({ path: '/vault/rg.md', title: 'Refactoring Guide', modifiedAt: 1700000004 }),
|
||||
makeEntry({ path: '/vault/rw.md', title: 'Refactoring Workflows', modifiedAt: 1700000003 }),
|
||||
makeEntry({ path: '/vault/rc.md', title: 'Refactoring Checklist', modifiedAt: 1700000002 }),
|
||||
makeEntry({ path: '/vault/r.md', title: 'Refactoring', isA: 'Area', modifiedAt: 1700000001 }),
|
||||
]
|
||||
const { result } = renderHook(() => useNoteSearch(ranked, 'Refactoring'))
|
||||
expect(result.current.results[0].title).toBe('Refactoring')
|
||||
})
|
||||
|
||||
it('ranks exact title match above note with alias exact match', () => {
|
||||
const ranked: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', aliases: ['Refactoring'], modifiedAt: 1700000003 }),
|
||||
makeEntry({ path: '/vault/rk.md', title: 'Refactoring Key Ideas', modifiedAt: 1700000002 }),
|
||||
makeEntry({ path: '/vault/r.md', title: 'Refactoring', modifiedAt: 1700000001 }),
|
||||
]
|
||||
const { result } = renderHook(() => useNoteSearch(ranked, 'Refactoring'))
|
||||
expect(result.current.results[0].title).toBe('Refactoring')
|
||||
})
|
||||
|
||||
it('ranks case-insensitive exact match first', () => {
|
||||
const ranked: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/qi.md', title: 'Quarter Ideas', modifiedAt: 1700000002 }),
|
||||
makeEntry({ path: '/vault/q.md', title: 'Quarter', modifiedAt: 1700000001 }),
|
||||
]
|
||||
const { result } = renderHook(() => useNoteSearch(ranked, 'quarter'))
|
||||
expect(result.current.results[0].title).toBe('Quarter')
|
||||
})
|
||||
|
||||
it('boosts note whose alias is an exact match', () => {
|
||||
const ranked: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/ri.md', title: 'Refactoring Ideas', modifiedAt: 1700000002 }),
|
||||
makeEntry({ path: '/vault/rn.md', title: 'Refactoring Notes', aliases: ['ref'], modifiedAt: 1700000001 }),
|
||||
]
|
||||
const { result } = renderHook(() => useNoteSearch(ranked, 'ref'))
|
||||
expect(result.current.results[0].title).toBe('Refactoring Notes')
|
||||
})
|
||||
|
||||
it('excludes trashed notes from results', () => {
|
||||
const withTrashed: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/a.md', title: 'Active Note', modifiedAt: 1700000003 }),
|
||||
makeEntry({ path: '/vault/t.md', title: 'Trashed Note', trashed: true, trashedAt: 1700000000, modifiedAt: 1700000002 }),
|
||||
makeEntry({ path: '/vault/b.md', title: 'Another Active', modifiedAt: 1700000001 }),
|
||||
]
|
||||
const { result } = renderHook(() => useNoteSearch(withTrashed, ''))
|
||||
expect(result.current.results.map(r => r.title)).toEqual(['Active Note', 'Another Active'])
|
||||
})
|
||||
|
||||
it('excludes trashed notes from search results by query', () => {
|
||||
const withTrashed: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/a.md', title: 'Meeting Notes', modifiedAt: 1700000002 }),
|
||||
makeEntry({ path: '/vault/t.md', title: 'Meeting Draft', trashed: true, modifiedAt: 1700000001 }),
|
||||
]
|
||||
const { result } = renderHook(() => useNoteSearch(withTrashed, 'Meeting'))
|
||||
expect(result.current.results).toHaveLength(1)
|
||||
expect(result.current.results[0].title).toBe('Meeting Notes')
|
||||
})
|
||||
|
||||
it('does not exclude archived notes from results', () => {
|
||||
const withArchived: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/a.md', title: 'Active Note', modifiedAt: 1700000002 }),
|
||||
makeEntry({ path: '/vault/ar.md', title: 'Archived Note', archived: true, modifiedAt: 1700000001 }),
|
||||
]
|
||||
const { result } = renderHook(() => useNoteSearch(withArchived, ''))
|
||||
expect(result.current.results).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resolves custom type color from Type entries', () => {
|
||||
const withTypes: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/t/recipe.md', title: 'Recipe', isA: 'Type', color: 'orange', icon: 'cooking-pot' }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { fuzzyMatch } from '../utils/fuzzyMatch'
|
||||
import { fuzzyMatch, bestSearchRank } from '../utils/fuzzyMatch'
|
||||
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { getTypeIcon } from '../components/NoteItem'
|
||||
import type { NoteSearchResultItem } from '../components/NoteSearchList'
|
||||
@@ -32,7 +32,7 @@ export function useNoteSearch(entries: VaultEntry[], query: string, maxResults =
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const searchableEntries = useMemo(
|
||||
() => entries.filter((e) => !SEARCH_EXCLUDED_TYPES.has(e.isA ?? '')),
|
||||
() => entries.filter((e) => !e.trashed && !SEARCH_EXCLUDED_TYPES.has(e.isA ?? '')),
|
||||
[entries],
|
||||
)
|
||||
|
||||
@@ -45,9 +45,13 @@ export function useNoteSearch(entries: VaultEntry[], query: string, maxResults =
|
||||
.map(mapResult)
|
||||
}
|
||||
return searchableEntries
|
||||
.map((e) => ({ entry: e, ...fuzzyMatch(query, e.title) }))
|
||||
.map((e) => ({
|
||||
entry: e,
|
||||
...fuzzyMatch(query, e.title),
|
||||
rank: bestSearchRank(query, e.title, e.aliases),
|
||||
}))
|
||||
.filter((r) => r.match)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.sort((a, b) => a.rank - b.rank || b.score - a.score)
|
||||
.slice(0, maxResults)
|
||||
.map((r) => mapResult(r.entry))
|
||||
}, [searchableEntries, query, maxResults, typeEntryMap])
|
||||
|
||||
@@ -2,17 +2,16 @@ import { useMemo, useState, useCallback } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import { parseFrontmatter } from '../utils/frontmatter'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
loadDisplayModeOverrides,
|
||||
saveDisplayModeOverride,
|
||||
removeDisplayModeOverride,
|
||||
} from '../utils/propertyTypes'
|
||||
import { RELATIONSHIP_KEYS, containsWikilinks } from '../components/DynamicPropertiesPanel'
|
||||
import { containsWikilinks } from '../components/DynamicPropertiesPanel'
|
||||
|
||||
// Keys to skip showing in Properties (handled by dedicated UI or internal)
|
||||
const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace', 'title', 'type', 'is_a', 'Is A'])
|
||||
const SKIP_KEYS = new Set(['aliases', 'workspace', 'title', 'type', 'is_a', 'Is A'])
|
||||
|
||||
function coerceValue(raw: string): FrontmatterValue {
|
||||
if (raw.toLowerCase() === 'true') return true
|
||||
@@ -62,22 +61,17 @@ function collectVaultStatuses(entries: VaultEntry[] | undefined): string[] {
|
||||
return Array.from(seen).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function mergeArrayFieldsInto(fm: ParsedFrontmatter, tagsByKey: Map<string, Set<string>>): void {
|
||||
for (const [key, value] of Object.entries(fm)) {
|
||||
if (!Array.isArray(value)) continue
|
||||
let set = tagsByKey.get(key)
|
||||
if (!set) { set = new Set(); tagsByKey.set(key, set) }
|
||||
for (const tag of value) set.add(String(tag))
|
||||
}
|
||||
}
|
||||
|
||||
function collectAllVaultTags(entries: VaultEntry[] | undefined, allContent: Record<string, string> | undefined): Record<string, string[]> {
|
||||
if (!entries || !allContent) return {}
|
||||
function collectAllVaultTags(entries: VaultEntry[] | undefined): Record<string, string[]> {
|
||||
if (!entries) return {}
|
||||
const tagsByKey = new Map<string, Set<string>>()
|
||||
for (const entry of entries) {
|
||||
const content = allContent[entry.path]
|
||||
if (!content) continue
|
||||
mergeArrayFieldsInto(parseFrontmatter(content), tagsByKey)
|
||||
if (!entry.properties) continue
|
||||
for (const [key, value] of Object.entries(entry.properties)) {
|
||||
if (!Array.isArray(value)) continue
|
||||
let set = tagsByKey.get(key)
|
||||
if (!set) { set = new Set(); tagsByKey.set(key, set) }
|
||||
for (const tag of value) set.add(String(tag))
|
||||
}
|
||||
}
|
||||
const result: Record<string, string[]> = {}
|
||||
for (const [key, set] of tagsByKey) result[key] = Array.from(set).sort((a, b) => a.localeCompare(b))
|
||||
@@ -85,7 +79,7 @@ function collectAllVaultTags(entries: VaultEntry[] | undefined, allContent: Reco
|
||||
}
|
||||
|
||||
function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
|
||||
return !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value)
|
||||
return !SKIP_KEYS.has(key) && !containsWikilinks(value)
|
||||
}
|
||||
|
||||
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
|
||||
@@ -106,21 +100,20 @@ export interface PropertyPanelDeps {
|
||||
entries: VaultEntry[] | undefined
|
||||
entryIsA: string | null
|
||||
frontmatter: ParsedFrontmatter
|
||||
allContent: Record<string, string> | undefined
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
}
|
||||
|
||||
export function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
const { entries, entryIsA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
|
||||
const { entries, entryIsA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides())
|
||||
|
||||
const { availableTypes, customColorKey, typeColorKeys, typeIconKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
|
||||
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
|
||||
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries, allContent), [entries, allContent])
|
||||
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries), [entries])
|
||||
const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter])
|
||||
|
||||
const handleSaveValue = useCallback((key: string, newValue: string) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke, updateMockContent } from '../mock-tauri'
|
||||
|
||||
async function persistContent(path: string, content: string): Promise<void> {
|
||||
export async function persistContent(path: string, content: string): Promise<void> {
|
||||
if (isTauri()) {
|
||||
await invoke('save_note_content', { path, content })
|
||||
} else {
|
||||
|
||||
@@ -6,7 +6,7 @@ const THEME_PATH_DEFAULT = '/vault/theme/default.md'
|
||||
const THEME_PATH_DARK = '/vault/theme/dark.md'
|
||||
|
||||
const DEFAULT_THEME_CONTENT = `---
|
||||
Is A: Theme
|
||||
type: Theme
|
||||
Description: Light theme
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
@@ -19,7 +19,7 @@ text-primary: "#37352F"
|
||||
`
|
||||
|
||||
const DARK_THEME_CONTENT = `---
|
||||
Is A: Theme
|
||||
type: Theme
|
||||
Description: Dark theme
|
||||
background: "#0f0f1a"
|
||||
foreground: "#e0e0e0"
|
||||
@@ -332,25 +332,6 @@ describe('useThemeManager', () => {
|
||||
expect(newPath).toBe('')
|
||||
})
|
||||
|
||||
it('re-applies theme when active content changes in allContent', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ content }) => useThemeManager('/vault', entries, content),
|
||||
{ initialProps: { content: allContent } },
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme).not.toBeNull()
|
||||
})
|
||||
|
||||
const newContent = {
|
||||
[THEME_PATH_DEFAULT]: `---\nIs A: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`,
|
||||
}
|
||||
rerender({ content: newContent })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000')
|
||||
})
|
||||
})
|
||||
|
||||
it('reloadThemes re-reads vault settings', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, allContent)
|
||||
@@ -422,26 +403,6 @@ describe('useThemeManager', () => {
|
||||
expect(document.documentElement.dataset.themeMode).toBe('dark')
|
||||
})
|
||||
|
||||
it('populates theme colors from allContent when available', async () => {
|
||||
const contentWithColors = {
|
||||
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
|
||||
[THEME_PATH_DARK]: DARK_THEME_CONTENT,
|
||||
}
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, contentWithColors)
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
const defaultTheme = result.current.themes.find(t => t.id === THEME_PATH_DEFAULT)
|
||||
expect(defaultTheme?.colors.background).toBe('#FFFFFF')
|
||||
expect(defaultTheme?.colors.primary).toBe('#155DFF')
|
||||
|
||||
const darkTheme = result.current.themes.find(t => t.id === THEME_PATH_DARK)
|
||||
expect(darkTheme?.colors.background).toBe('#0f0f1a')
|
||||
})
|
||||
|
||||
it('isDark detects dark theme from cached content', async () => {
|
||||
const contentWithColors = {
|
||||
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
|
||||
|
||||
@@ -197,8 +197,6 @@ function useThemeApplier(
|
||||
export function useThemeManager(
|
||||
vaultPath: string | null,
|
||||
entries: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
updateContent?: (path: string, content: string) => void,
|
||||
): ThemeManager {
|
||||
// Ensure default theme files exist on vault open (creates theme/ dir + defaults if missing)
|
||||
useEffect(() => {
|
||||
@@ -206,7 +204,12 @@ export function useThemeManager(
|
||||
}, [vaultPath])
|
||||
|
||||
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
|
||||
const cachedThemeContent = activeThemeId ? allContent[activeThemeId] : undefined
|
||||
const [cachedThemeContent, setCachedThemeContent] = useState<string | undefined>(undefined)
|
||||
|
||||
// Clear cached content when theme changes — useThemeApplier will fetch from disk
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setCachedThemeContent(undefined) }, [activeThemeId])
|
||||
|
||||
const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent)
|
||||
|
||||
// Track IDs set by user actions (switchTheme/createTheme) so the stale-ID
|
||||
@@ -216,8 +219,8 @@ export function useThemeManager(
|
||||
const themes = useMemo(
|
||||
() => entries
|
||||
.filter(e => e.isA === 'Theme' && !e.trashed && !e.archived)
|
||||
.map(e => entryToThemeFile(e, allContent[e.path])),
|
||||
[entries, allContent],
|
||||
.map(e => entryToThemeFile(e, e.path === activeThemeId ? cachedThemeContent : undefined)),
|
||||
[entries, activeThemeId, cachedThemeContent],
|
||||
)
|
||||
|
||||
const activeTheme = useMemo(
|
||||
@@ -280,9 +283,9 @@ export function useThemeManager(
|
||||
key,
|
||||
value,
|
||||
})
|
||||
updateContent?.(activeThemeId, newContent)
|
||||
setCachedThemeContent(newContent)
|
||||
} catch (err) { console.error('Failed to update theme property:', err) }
|
||||
}, [activeThemeId, updateContent])
|
||||
}, [activeThemeId])
|
||||
|
||||
return {
|
||||
themes, activeThemeId, activeTheme,
|
||||
|
||||
@@ -61,11 +61,10 @@ describe('useVaultLoader', () => {
|
||||
mockInvokeFn.mockImplementation(defaultMockInvoke)
|
||||
})
|
||||
|
||||
it('loads entries and content on mount', async () => {
|
||||
it('loads entries on mount', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
expect(result.current.entries[0].title).toBe('Hello')
|
||||
expect(result.current.allContent['/vault/note/hello.md']).toContain('# Hello')
|
||||
})
|
||||
|
||||
it('loads modified files on mount', async () => {
|
||||
@@ -79,15 +78,14 @@ describe('useVaultLoader', () => {
|
||||
})
|
||||
|
||||
describe('addEntry', () => {
|
||||
it('prepends new entry and adds content', async () => {
|
||||
it('prepends new entry', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New Note' }
|
||||
|
||||
act(() => { result.current.addEntry(newEntry, '# New Note') })
|
||||
act(() => { result.current.addEntry(newEntry) })
|
||||
|
||||
expect(result.current.entries).toHaveLength(2)
|
||||
expect(result.current.entries[0].title).toBe('New Note')
|
||||
expect(result.current.allContent['/vault/note/new.md']).toBe('# New Note')
|
||||
})
|
||||
|
||||
it('ignores duplicate entry with same path', async () => {
|
||||
@@ -95,32 +93,21 @@ describe('useVaultLoader', () => {
|
||||
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/new.md', filename: 'new.md', title: 'New Note' }
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# New Note')
|
||||
result.current.addEntry(newEntry, '# New Note')
|
||||
result.current.addEntry(newEntry)
|
||||
result.current.addEntry(newEntry)
|
||||
})
|
||||
|
||||
expect(result.current.entries).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateContent', () => {
|
||||
it('updates content for an existing path', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
act(() => { result.current.updateContent('/vault/note/hello.md', '# Updated') })
|
||||
|
||||
expect(result.current.allContent['/vault/note/hello.md']).toBe('# Updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeEntry', () => {
|
||||
it('removes entry and content by path', async () => {
|
||||
it('removes entry by path', async () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
|
||||
act(() => { result.current.removeEntry('/vault/note/hello.md') })
|
||||
|
||||
expect(result.current.entries).toHaveLength(0)
|
||||
expect(result.current.allContent['/vault/note/hello.md']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is a no-op for non-existent paths', async () => {
|
||||
@@ -159,7 +146,7 @@ describe('useVaultLoader', () => {
|
||||
const { result } = await renderVaultLoader()
|
||||
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/brand-new.md', filename: 'brand-new.md', title: 'Brand New' }
|
||||
|
||||
act(() => { result.current.addEntry(newEntry, '# Brand New') })
|
||||
act(() => { result.current.addEntry(newEntry) })
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/brand-new.md')).toBe('new')
|
||||
})
|
||||
@@ -227,7 +214,7 @@ describe('useVaultLoader', () => {
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# New')
|
||||
result.current.addEntry(newEntry)
|
||||
})
|
||||
|
||||
expect(result.current.getNoteStatus('/vault/note/new.md')).toBe('new')
|
||||
@@ -238,7 +225,7 @@ describe('useVaultLoader', () => {
|
||||
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# Draft')
|
||||
result.current.addEntry(newEntry)
|
||||
result.current.trackUnsaved('/vault/note/draft.md')
|
||||
})
|
||||
|
||||
@@ -250,7 +237,7 @@ describe('useVaultLoader', () => {
|
||||
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# Draft')
|
||||
result.current.addEntry(newEntry)
|
||||
result.current.trackUnsaved('/vault/note/draft.md')
|
||||
})
|
||||
|
||||
@@ -263,7 +250,7 @@ describe('useVaultLoader', () => {
|
||||
const newEntry: VaultEntry = { ...mockEntries[0], path: '/vault/note/draft.md', filename: 'draft.md', title: 'Draft' }
|
||||
|
||||
act(() => {
|
||||
result.current.addEntry(newEntry, '# Draft')
|
||||
result.current.addEntry(newEntry)
|
||||
result.current.trackUnsaved('/vault/note/draft.md')
|
||||
})
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ async function loadVaultData(vaultPath: string) {
|
||||
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
|
||||
const entries = await tauriCall<VaultEntry[]>('list_vault', { path: vaultPath })
|
||||
console.log(`Vault scan complete: ${entries.length} entries found`)
|
||||
const allContent = isTauri() ? {} : await mockInvoke<Record<string, string>>('get_all_content', { path: vaultPath })
|
||||
return { entries, allContent }
|
||||
return { entries }
|
||||
}
|
||||
|
||||
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
|
||||
@@ -91,7 +90,6 @@ export function resolveNoteStatus(
|
||||
|
||||
export function useVaultLoader(vaultPath: string) {
|
||||
const [entries, setEntries] = useState<VaultEntry[]>([])
|
||||
const [allContent, setAllContent] = useState<Record<string, string>>({})
|
||||
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
|
||||
const [modifiedFilesError, setModifiedFilesError] = useState<string | null>(null)
|
||||
const tracker = useNewNoteTracker()
|
||||
@@ -100,9 +98,9 @@ export function useVaultLoader(vaultPath: string) {
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
|
||||
setEntries([]); setAllContent({}); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
|
||||
setEntries([]); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
|
||||
loadVaultData(vaultPath)
|
||||
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
|
||||
.then(({ entries: e }) => { setEntries(e) })
|
||||
.catch((err) => console.warn('Vault scan failed:', err))
|
||||
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- tracker.clear is stable
|
||||
|
||||
@@ -122,31 +120,25 @@ export function useVaultLoader(vaultPath: string) {
|
||||
|
||||
// PERF: startTransition defers the expensive entries update (filter/sort on
|
||||
// 9000+ entries) so the high-priority tab render completes in <50ms first.
|
||||
const addEntry = useCallback((entry: VaultEntry, content: string) => {
|
||||
const addEntry = useCallback((entry: VaultEntry) => {
|
||||
startTransition(() => {
|
||||
setEntries((prev) => {
|
||||
if (prev.some(e => e.path === entry.path)) return prev
|
||||
return [entry, ...prev]
|
||||
})
|
||||
setAllContent((prev) => ({ ...prev, [entry.path]: content }))
|
||||
tracker.trackNew(entry.path)
|
||||
})
|
||||
}, [tracker])
|
||||
|
||||
const updateContent = useCallback((path: string, content: string) =>
|
||||
setAllContent((prev) => ({ ...prev, [path]: content })), [])
|
||||
|
||||
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) =>
|
||||
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e)), [])
|
||||
|
||||
const removeEntry = useCallback((path: string) => {
|
||||
setEntries((prev) => prev.filter((e) => e.path !== path))
|
||||
setAllContent((prev) => { const next = { ...prev }; delete next[path]; return next })
|
||||
}, [])
|
||||
|
||||
const replaceEntry = useCallback((oldPath: string, patch: Partial<VaultEntry> & { path: string }, newContent: string) => {
|
||||
const replaceEntry = useCallback((oldPath: string, patch: Partial<VaultEntry> & { path: string }) => {
|
||||
setEntries((prev) => prev.map((e) => e.path === oldPath ? { ...e, ...patch } : e))
|
||||
setAllContent((prev) => { const next = { ...prev }; delete next[oldPath]; next[patch.path] = newContent; return next })
|
||||
}, [])
|
||||
|
||||
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
|
||||
@@ -167,15 +159,15 @@ export function useVaultLoader(vaultPath: string) {
|
||||
commitWithPush(vaultPath, message), [vaultPath])
|
||||
|
||||
const reloadVault = useCallback(
|
||||
() => loadVaultData(vaultPath)
|
||||
.then((data) => { setEntries(data.entries); setAllContent((prev) => ({ ...prev, ...data.allContent })); loadModifiedFiles(); return data.entries })
|
||||
() => tauriCall<VaultEntry[]>('reload_vault', { path: vaultPath })
|
||||
.then((entries) => { setEntries(entries); loadModifiedFiles(); return entries })
|
||||
.catch((err) => { console.warn('Vault reload failed:', err); return [] as VaultEntry[] }),
|
||||
[vaultPath, loadModifiedFiles],
|
||||
)
|
||||
|
||||
return {
|
||||
entries, allContent, modifiedFiles, modifiedFilesError,
|
||||
addEntry, updateEntry, removeEntry, replaceEntry, updateContent,
|
||||
entries, modifiedFiles, modifiedFilesError,
|
||||
addEntry, updateEntry, removeEntry, replaceEntry,
|
||||
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
|
||||
getNoteStatus, commitAndPush, reloadVault,
|
||||
addPendingSave: pendingSave.addPendingSave,
|
||||
|
||||
@@ -109,4 +109,46 @@ describe('useZoom', () => {
|
||||
const { result } = renderHook(() => useZoom())
|
||||
expect(result.current.zoomLevel).toBe(100)
|
||||
})
|
||||
|
||||
it('dispatches laputa-zoom-change event on zoomIn', () => {
|
||||
const handler = vi.fn()
|
||||
window.addEventListener('laputa-zoom-change', handler)
|
||||
const { result } = renderHook(() => useZoom())
|
||||
handler.mockClear() // clear any init-phase dispatches
|
||||
act(() => result.current.zoomIn())
|
||||
expect(handler).toHaveBeenCalled()
|
||||
window.removeEventListener('laputa-zoom-change', handler)
|
||||
})
|
||||
|
||||
it('dispatches laputa-zoom-change event on zoomOut', () => {
|
||||
const handler = vi.fn()
|
||||
window.addEventListener('laputa-zoom-change', handler)
|
||||
const { result } = renderHook(() => useZoom())
|
||||
handler.mockClear()
|
||||
act(() => result.current.zoomOut())
|
||||
expect(handler).toHaveBeenCalled()
|
||||
window.removeEventListener('laputa-zoom-change', handler)
|
||||
})
|
||||
|
||||
it('dispatches laputa-zoom-change event on zoomReset', () => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
|
||||
const handler = vi.fn()
|
||||
window.addEventListener('laputa-zoom-change', handler)
|
||||
const { result } = renderHook(() => useZoom())
|
||||
handler.mockClear()
|
||||
act(() => result.current.zoomReset())
|
||||
expect(handler).toHaveBeenCalled()
|
||||
window.removeEventListener('laputa-zoom-change', handler)
|
||||
})
|
||||
|
||||
it('applies CSS zoom synchronously during initialization', () => {
|
||||
resetVaultConfigStore()
|
||||
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
|
||||
const spy = vi.spyOn(document.documentElement.style, 'setProperty')
|
||||
renderHook(() => useZoom())
|
||||
// Zoom should be applied during state init (setProperty called with zoom value)
|
||||
expect(spy).toHaveBeenCalledWith('zoom', '120%')
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ function loadPersistedZoom(): number {
|
||||
|
||||
function applyZoomToDocument(level: number): void {
|
||||
document.documentElement.style.setProperty('zoom', `${level}%`)
|
||||
window.dispatchEvent(new Event('laputa-zoom-change'))
|
||||
}
|
||||
|
||||
function persistZoom(level: number): void {
|
||||
@@ -36,12 +37,13 @@ function persistZoom(level: number): void {
|
||||
}
|
||||
|
||||
export function useZoom() {
|
||||
const [zoomLevel, setZoomLevel] = useState(loadPersistedZoom)
|
||||
|
||||
// Apply persisted zoom on mount
|
||||
useEffect(() => {
|
||||
applyZoomToDocument(zoomLevel)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- only on mount
|
||||
const [zoomLevel, setZoomLevel] = useState(() => {
|
||||
const level = loadPersistedZoom()
|
||||
// Apply zoom synchronously during init so child components (e.g. CodeMirror)
|
||||
// measure the correct scale factor in their own effects.
|
||||
document.documentElement.style.setProperty('zoom', `${level}%`)
|
||||
return level
|
||||
})
|
||||
|
||||
// Re-sync when vault config becomes available
|
||||
useEffect(() => {
|
||||
|
||||
@@ -734,7 +734,7 @@ rating: 5
|
||||
Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/default.md': `---
|
||||
Is A: Theme
|
||||
type: Theme
|
||||
title: Default
|
||||
primary: "#155DFF"
|
||||
background: "#FFFFFF"
|
||||
@@ -755,7 +755,7 @@ line-height-base: 1.6
|
||||
Light theme with warm, paper-like tones.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/dark.md': `---
|
||||
Is A: Theme
|
||||
type: Theme
|
||||
title: Dark
|
||||
primary: "#155DFF"
|
||||
background: "#0f0f1a"
|
||||
@@ -776,7 +776,7 @@ line-height-base: 1.6
|
||||
Dark variant with deep navy tones.
|
||||
`,
|
||||
'/Users/luca/Laputa/theme/minimal.md': `---
|
||||
Is A: Theme
|
||||
type: Theme
|
||||
title: Minimal
|
||||
primary: "#000000"
|
||||
background: "#FAFAFA"
|
||||
|
||||
@@ -1099,6 +1099,119 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Type': ['[[type/experiment]]'],
|
||||
},
|
||||
},
|
||||
// --- Refactoring entries for exact-match search testing ---
|
||||
{
|
||||
path: '/Users/luca/Laputa/area/refactoring.md',
|
||||
filename: 'refactoring.md',
|
||||
title: 'Refactoring',
|
||||
isA: 'Area',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 30,
|
||||
createdAt: now - 86400 * 365,
|
||||
fileSize: 1200,
|
||||
snippet: 'Area note covering refactoring practices and principles.',
|
||||
wordCount: 180,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/refactoring-ideas.md',
|
||||
filename: 'refactoring-ideas.md',
|
||||
title: 'Refactoring Ideas',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 5,
|
||||
createdAt: now - 86400 * 60,
|
||||
fileSize: 800,
|
||||
snippet: 'Ideas for refactoring the codebase.',
|
||||
wordCount: 120,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/refactoring-key-ideas.md',
|
||||
filename: 'refactoring-key-ideas.md',
|
||||
title: 'Refactoring Key Ideas',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 10,
|
||||
createdAt: now - 86400 * 90,
|
||||
fileSize: 600,
|
||||
snippet: 'Key ideas from the refactoring book.',
|
||||
wordCount: 95,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/refactoring-patterns.md',
|
||||
filename: 'refactoring-patterns.md',
|
||||
title: 'Refactoring Patterns',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 15,
|
||||
createdAt: now - 86400 * 120,
|
||||
fileSize: 950,
|
||||
snippet: 'Common refactoring patterns and when to apply them.',
|
||||
wordCount: 150,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
// --- Bulk entry generator for large vault testing ---
|
||||
|
||||
@@ -150,6 +150,8 @@ const trimOrNull = (v: string | null | undefined): string | null => v?.trim() ||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
|
||||
export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
list_vault: () => MOCK_ENTRIES,
|
||||
reload_vault: () => MOCK_ENTRIES,
|
||||
reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, trashed: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {} },
|
||||
get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '',
|
||||
get_all_content: () => MOCK_CONTENT,
|
||||
get_file_history: (args: { path: string }) => mockFileHistory(args.path),
|
||||
@@ -219,6 +221,19 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
|
||||
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
|
||||
rename_note: handleRenameNote,
|
||||
move_note_to_type_folder: (args: { vault_path: string; note_path: string; new_type: string }) => {
|
||||
const slug = args.new_type.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const currentFolder = args.note_path.replace(/\/[^/]+$/, '').split('/').pop() ?? ''
|
||||
if (currentFolder === slug) return { new_path: args.note_path, updated_links: 0, moved: false }
|
||||
const filename = args.note_path.split('/').pop() ?? ''
|
||||
const vaultPath = args.vault_path.replace(/\/$/, '')
|
||||
const newPath = `${vaultPath}/${slug}/${filename}`
|
||||
const content = MOCK_CONTENT[args.note_path] ?? ''
|
||||
delete MOCK_CONTENT[args.note_path]
|
||||
MOCK_CONTENT[newPath] = content
|
||||
syncWindowContent()
|
||||
return { new_path: newPath, updated_links: 0, moved: true }
|
||||
},
|
||||
github_list_repos: () => [
|
||||
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
|
||||
{ name: 'laputa-app', full_name: 'lucaong/laputa-app', description: 'Laputa desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/laputa-app.git', html_url: 'https://github.com/lucaong/laputa-app', updated_at: '2026-02-19T15:00:00Z' },
|
||||
@@ -254,6 +269,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
if (!q) return { results: [], elapsed_ms: 0, query: q, mode: args.mode }
|
||||
const matches = MOCK_ENTRIES
|
||||
.filter(e => {
|
||||
if (e.trashed) return false
|
||||
const content = MOCK_CONTENT[e.path] ?? ''
|
||||
return e.title.toLowerCase().includes(q) || content.toLowerCase().includes(q)
|
||||
})
|
||||
@@ -301,7 +317,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme'
|
||||
const path = `${args.vaultPath}/theme/${slug}.md`
|
||||
MOCK_CONTENT[path] = `---
|
||||
Is A: Theme
|
||||
type: Theme
|
||||
title: ${displayName}
|
||||
primary: "#155DFF"
|
||||
background: "#FFFFFF"
|
||||
|
||||
@@ -20,6 +20,7 @@ async function checkVaultApi(): Promise<boolean> {
|
||||
|
||||
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => string | null> = {
|
||||
list_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
reload_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` : null,
|
||||
get_note_content: (args) => args.path ? `/api/vault/content?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
get_all_content: (args) => args.path ? `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` : null,
|
||||
}
|
||||
|
||||
@@ -46,6 +46,22 @@ export interface AgentStreamCallbacks {
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a mock response for browser/test mode.
|
||||
* Inspects the message for conversation history so Playwright tests
|
||||
* can verify that history is actually being sent.
|
||||
*/
|
||||
function mockAgentResponse(message: string): string {
|
||||
if (message.includes('<conversation_history>')) {
|
||||
const allUserLines = message.match(/\[user\]: .+/g) ?? []
|
||||
const turnCount = allUserLines.length
|
||||
const lastLine = allUserLines[allUserLines.length - 1] ?? ''
|
||||
const lastUserMsg = lastLine.replace('[user]: ', '')
|
||||
return `[mock-with-history turns=${turnCount}] You asked: "${lastUserMsg}" — This note is related to [[Build Laputa App]] and [[Matteo Cellini]].`
|
||||
}
|
||||
return `[mock-no-history] You said: "${message}" — This note is related to [[Build Laputa App]] and [[Matteo Cellini]].`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream an agent task through the Claude CLI subprocess with full tool access.
|
||||
* The CLI handles the tool-use loop; we receive events for UI updates.
|
||||
@@ -58,7 +74,7 @@ export async function streamClaudeAgent(
|
||||
): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
setTimeout(() => {
|
||||
callbacks.onText('This note is related to [[Build Laputa App]] and [[Matteo Cellini]]. The AI Agent requires the Claude CLI for full functionality.')
|
||||
callbacks.onText(mockAgentResponse(message))
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
return
|
||||
|
||||
@@ -38,25 +38,23 @@ describe('buildSystemPrompt', () => {
|
||||
})
|
||||
|
||||
it('returns empty prompt for no notes', () => {
|
||||
const result = buildSystemPrompt([], {})
|
||||
const result = buildSystemPrompt([])
|
||||
expect(result.prompt).toBe('')
|
||||
expect(result.totalTokens).toBe(0)
|
||||
expect(result.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it('includes note content in the prompt', () => {
|
||||
it('includes note metadata in the prompt', () => {
|
||||
const notes = [makeEntry('/test.md', 'Test Note')]
|
||||
const content = { '/test.md': '# Test Note\nHello world' }
|
||||
const result = buildSystemPrompt(notes, content)
|
||||
const result = buildSystemPrompt(notes)
|
||||
expect(result.prompt).toContain('Test Note')
|
||||
expect(result.prompt).toContain('Hello world')
|
||||
expect(result.prompt).toContain('/test.md')
|
||||
expect(result.totalTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('instructs AI to use wikilink syntax', () => {
|
||||
const notes = [makeEntry('/test.md', 'Test Note')]
|
||||
const content = { '/test.md': 'content' }
|
||||
const result = buildSystemPrompt(notes, content)
|
||||
const result = buildSystemPrompt(notes)
|
||||
expect(result.prompt).toContain('[[')
|
||||
expect(result.prompt).toMatch(/wikilink/i)
|
||||
})
|
||||
@@ -202,8 +200,29 @@ describe('streamClaudeChat', () => {
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
expect(sessionId).toBe('mock-session')
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('[mock-no-history]'))
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mock detects conversation history in message', async () => {
|
||||
const onText = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
const onError = vi.fn()
|
||||
|
||||
const msgWithHistory = formatMessageWithHistory(
|
||||
[{ role: 'user', content: 'What is 2+2?', id: 'm1' }, { role: 'assistant', content: '4', id: 'm2' }],
|
||||
'What was my previous question?',
|
||||
)
|
||||
|
||||
await streamClaudeChat(msgWithHistory, undefined, undefined, {
|
||||
onText, onError, onDone,
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('[mock-with-history'))
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('turns=2'))
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('What was my previous question?'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,47 +21,31 @@ export function getContextLimit(): number {
|
||||
|
||||
// --- Context building ---
|
||||
|
||||
/** Build system prompt from selected context notes. */
|
||||
/** Build system prompt from selected context notes (metadata only — content loaded via MCP). */
|
||||
export function buildSystemPrompt(
|
||||
notes: VaultEntry[],
|
||||
allContent: Record<string, string>,
|
||||
): { prompt: string; totalTokens: number; truncated: boolean } {
|
||||
if (notes.length === 0) {
|
||||
return { prompt: '', totalTokens: 0, truncated: false }
|
||||
}
|
||||
|
||||
const contextBudget = Math.floor(getContextLimit() * 0.6)
|
||||
const preamble = [
|
||||
'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
|
||||
'The user has selected the following notes as context. Use them to answer questions accurately.',
|
||||
'You can use MCP tools to read the full content of any note.',
|
||||
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const parts: string[] = [preamble]
|
||||
let totalChars = preamble.length
|
||||
let truncated = false
|
||||
|
||||
for (const note of notes) {
|
||||
const content = allContent[note.path] ?? ''
|
||||
const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) ---`
|
||||
const noteText = `${header}\n${content}\n`
|
||||
|
||||
if (estimateTokens(totalChars + noteText.length) > contextBudget) {
|
||||
const remaining = (contextBudget - estimateTokens(totalChars)) * 4
|
||||
if (remaining > 200) {
|
||||
parts.push(`${header}\n${content.slice(0, remaining)}\n[... truncated ...]`)
|
||||
}
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
|
||||
parts.push(noteText)
|
||||
totalChars += noteText.length
|
||||
const header = `--- Note: ${note.title} (${note.isA ?? 'Note'}) | Path: ${note.path} ---`
|
||||
parts.push(header)
|
||||
}
|
||||
|
||||
const prompt = parts.join('\n')
|
||||
return { prompt, totalTokens: estimateTokens(prompt), truncated }
|
||||
return { prompt, totalTokens: estimateTokens(prompt), truncated: false }
|
||||
}
|
||||
|
||||
// --- Message types ---
|
||||
@@ -164,6 +148,23 @@ function handleChatStreamEvent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a mock response for browser/test mode.
|
||||
* Inspects the message for conversation history so Playwright tests
|
||||
* can verify that history is actually being sent.
|
||||
*/
|
||||
function mockChatResponse(message: string): string {
|
||||
if (message.includes('<conversation_history>')) {
|
||||
const allUserLines = message.match(/\[user\]: .+/g) ?? []
|
||||
const turnCount = allUserLines.length
|
||||
// The last [user] line is the actual new message
|
||||
const lastLine = allUserLines[allUserLines.length - 1] ?? ''
|
||||
const lastUserMsg = lastLine.replace('[user]: ', '')
|
||||
return `[mock-with-history turns=${turnCount}] You asked: "${lastUserMsg}"`
|
||||
}
|
||||
return `[mock-no-history] You said: "${message}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a chat message through the Claude CLI subprocess.
|
||||
* Returns the session ID for conversation continuity via --resume.
|
||||
@@ -176,7 +177,7 @@ export async function streamClaudeChat(
|
||||
): Promise<string> {
|
||||
if (!isTauri()) {
|
||||
setTimeout(() => {
|
||||
callbacks.onText('AI Chat requires the Claude CLI. Install it and run the native app.')
|
||||
callbacks.onText(mockChatResponse(message))
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
return 'mock-session'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user